diff --git a/phpstan-baseline-7.4.neon b/phpstan-baseline-7.4.neon index 9e1cbc3b6a..beddd115ca 100644 --- a/phpstan-baseline-7.4.neon +++ b/phpstan-baseline-7.4.neon @@ -110,11 +110,6 @@ parameters: count: 1 path: src/lib/Form/EventListener/AddLanguageFieldBasedOnContentListener.php - - - message: "#^Parameter \\#2 \\$pieces of function implode expects array, array\\|null given\\.$#" - count: 2 - path: src/lib/Form/Factory/FormFactory.php - - message: "#^Parameter \\#1 \\$input of function array_filter expects array, iterable\\ given\\.$#" count: 1 diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b1a2450da3..02d630d0c3 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -726,12 +726,6 @@ parameters: count: 1 path: src/bundle/Controller/LocationController.php - - - message: '#^Parameter \#1 \$content of class Symfony\\Component\\HttpFoundation\\Response constructor expects string\|null, string\|false given\.$#' - identifier: argument.type - count: 1 - path: src/bundle/Controller/NotificationController.php - - message: '#^Argument of an invalid type array\\|null supplied for foreach, only iterables are supported\.$#' identifier: foreach.nonIterable @@ -7236,12 +7230,6 @@ parameters: count: 1 path: src/lib/Form/Factory/FormFactory.php - - - message: '#^Cannot access property \$id on Ibexa\\Contracts\\Core\\Repository\\Values\\Content\\Section\|null\.$#' - identifier: property.nonObject - count: 2 - path: src/lib/Form/Factory/FormFactory.php - - message: '#^Cannot access property \$id on Ibexa\\Contracts\\Core\\Repository\\Values\\User\\Role\|null\.$#' identifier: property.nonObject @@ -7284,18 +7272,6 @@ parameters: count: 2 path: src/lib/Form/Factory/FormFactory.php - - - message: '#^Cannot call method getSection\(\) on Ibexa\\AdminUi\\Form\\Data\\Section\\SectionDeleteData\|null\.$#' - identifier: method.nonObject - count: 1 - path: src/lib/Form/Factory/FormFactory.php - - - - message: '#^Cannot call method getSection\(\) on Ibexa\\AdminUi\\Form\\Data\\Section\\SectionUpdateData\|null\.$#' - identifier: method.nonObject - count: 1 - path: src/lib/Form/Factory/FormFactory.php - - message: '#^Method Ibexa\\AdminUi\\Form\\Factory\\FormFactory\:\:addCustomUrl\(\) return type with generic interface Symfony\\Component\\Form\\FormInterface does not specify its types\: TData$#' identifier: missingType.generics @@ -7668,12 +7644,6 @@ parameters: count: 1 path: src/lib/Form/Factory/FormFactory.php - - - message: '#^Parameter \#1 \$name of method Symfony\\Component\\Form\\FormFactoryInterface\:\:createNamed\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 42 - path: src/lib/Form/Factory/FormFactory.php - - message: '#^Property Ibexa\\AdminUi\\Form\\Factory\\FormFactory\:\:\$translator is never read, only written\.$#' identifier: property.onlyWritten diff --git a/src/bundle/Controller/AllNotificationsController.php b/src/bundle/Controller/AllNotificationsController.php new file mode 100644 index 0000000000..036312de44 --- /dev/null +++ b/src/bundle/Controller/AllNotificationsController.php @@ -0,0 +1,28 @@ +forward( + NotificationController::class . '::renderNotificationsPageAction', + [ + 'page' => $page, + 'template' => '@ibexadesign/account/notifications/list_all.html.twig', + 'render_all' => true, + ] + ); + } +} diff --git a/src/bundle/Controller/NotificationController.php b/src/bundle/Controller/NotificationController.php index 5cfb12cbaa..cb1bef7178 100644 --- a/src/bundle/Controller/NotificationController.php +++ b/src/bundle/Controller/NotificationController.php @@ -8,149 +8,195 @@ namespace Ibexa\Bundle\AdminUi\Controller; +use DateTimeInterface; +use Ibexa\AdminUi\Form\Data\Notification\NotificationSelectionData; +use Ibexa\AdminUi\Form\Factory\FormFactory; +use Ibexa\AdminUi\Form\SubmitHandler; +use Ibexa\AdminUi\Form\Type\Notification\SearchType; use Ibexa\AdminUi\Pagination\Pagerfanta\NotificationAdapter; -use Ibexa\Bundle\AdminUi\View\EzPagerfantaView; -use Ibexa\Bundle\AdminUi\View\Template\EzPagerfantaTemplate; +use Ibexa\Bundle\AdminUi\Form\Data\SearchQueryData; use Ibexa\Contracts\AdminUi\Controller\Controller; +use Ibexa\Contracts\AdminUi\Notification\TranslatableNotificationHandlerInterface; +use Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException; use Ibexa\Contracts\Core\Repository\NotificationService; +use Ibexa\Contracts\Core\Repository\Values\Notification\Query\Criterion; +use Ibexa\Contracts\Core\Repository\Values\Notification\Query\NotificationQuery; use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; use Ibexa\Core\Notification\Renderer\Registry; +use InvalidArgumentException; +use JMS\TranslationBundle\Annotation\Desc; use Pagerfanta\Pagerfanta; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Contracts\Translation\TranslatorInterface; +use Throwable; -class NotificationController extends Controller +final class NotificationController extends Controller { - /** @var \Ibexa\Contracts\Core\Repository\NotificationService */ - protected $notificationService; + protected NotificationService $notificationService; - /** @var \Ibexa\Core\Notification\Renderer\Registry */ - protected $registry; + protected Registry $registry; - /** @var \Symfony\Contracts\Translation\TranslatorInterface */ - protected $translator; + protected TranslatorInterface $translator; - /** @var \Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface */ - private $configResolver; + private ConfigResolverInterface $configResolver; + + private FormFactory $formFactory; + + private SubmitHandler $submitHandler; + + private TranslatableNotificationHandlerInterface $notificationHandler; public function __construct( NotificationService $notificationService, Registry $registry, TranslatorInterface $translator, - ConfigResolverInterface $configResolver + ConfigResolverInterface $configResolver, + FormFactory $formFactory, + SubmitHandler $submitHandler, + TranslatableNotificationHandlerInterface $notificationHandler ) { $this->notificationService = $notificationService; $this->registry = $registry; $this->translator = $translator; $this->configResolver = $configResolver; + $this->formFactory = $formFactory; + $this->submitHandler = $submitHandler; + $this->notificationHandler = $notificationHandler; } /** - * @param \Symfony\Component\HttpFoundation\Request $request - * @param int $offset - * @param int $limit - * - * @return \Symfony\Component\HttpFoundation\JsonResponse + * @param callable(): JsonResponse $callback */ - public function getNotificationsAction(Request $request, int $offset, int $limit): JsonResponse + private function handleJsonErrors(callable $callback): JsonResponse { - $response = new JsonResponse(); - try { + return $callback(); + } catch (NotFoundException $exception) { + return new JsonResponse([ + 'status' => 'failed', + 'error' => $exception->getMessage(), + ], 404); + } catch (Throwable $exception) { + return new JsonResponse([ + 'status' => 'failed', + 'error' => 'Unexpected error occurred.', + ], 500); + } + } + + public function getNotificationsAction(Request $request, int $offset, int $limit): JsonResponse + { + return $this->handleJsonErrors(function () use ($offset, $limit) { $notificationList = $this->notificationService->loadNotifications($offset, $limit); - $response->setData([ + + return new JsonResponse([ 'pending' => $this->notificationService->getPendingNotificationCount(), 'total' => $notificationList->totalCount, 'notifications' => $notificationList->items, ]); - } catch (\Exception $exception) { - $response->setData([ - 'status' => 'failed', - 'error' => $exception->getMessage(), - ]); - } - - return $response; + }); } - /** - * @param int $page - * - * @return \Symfony\Component\HttpFoundation\Response - */ - public function renderNotificationsPageAction(int $page): Response + public function renderNotificationsPageAction(Request $request, int $page): Response { + $searchForm = $this->createForm(SearchType::class); + $searchForm->handleRequest($request); + + $query = new NotificationQuery(); + if ($searchForm->isSubmitted() && $searchForm->isValid()) { + $query = $this->buildQuery($searchForm->getData()); + } + $pagerfanta = new Pagerfanta( - new NotificationAdapter($this->notificationService) + new NotificationAdapter($this->notificationService, $query) ); $pagerfanta->setMaxPerPage($this->configResolver->getParameter('pagination.notification_limit')); $pagerfanta->setCurrentPage(min($page, $pagerfanta->getNbPages())); - $notifications = ''; + $notifications = []; foreach ($pagerfanta->getCurrentPageResults() as $notification) { if ($this->registry->hasRenderer($notification->type)) { - $renderer = $this->registry->getRenderer($notification->type); - $notifications .= $renderer->render($notification); + $notifications[] = $this->registry->getRenderer($notification->type)->render($notification); } } - $routeGenerator = function ($page) { - return $this->generateUrl('ibexa.notifications.render.page', [ - 'page' => $page, - ]); - }; + $formData = $this->createNotificationSelectionData($pagerfanta); + $deleteForm = $this->formFactory->deleteNotification($formData); - $pagination = (new EzPagerfantaView(new EzPagerfantaTemplate($this->translator)))->render($pagerfanta, $routeGenerator); + $template = $request->attributes->get('template', '@ibexadesign/account/notifications/list.html.twig'); - return new Response($this->render('@ibexadesign/account/notifications/list.html.twig', [ - 'page' => $page, - 'pagination' => $pagination, + return $this->render($template, [ 'notifications' => $notifications, 'notifications_count_interval' => $this->configResolver->getParameter('notification_count.interval'), 'pager' => $pagerfanta, - ])->getContent()); + 'search_form' => $searchForm->createView(), + 'delete_form' => $deleteForm->createView(), + ]); + } + + private function buildQuery(?SearchQueryData $data): NotificationQuery + { + if ($data === null) { + return new NotificationQuery([]); + } + + $criteria = []; + + if ($data->getType()) { + $criteria[] = new Criterion\Type($data->getType()); + } + + if (!empty($data->getStatuses())) { + $criteria[] = new Criterion\Status($data->getStatuses()); + } + + $range = $data->getCreatedRange(); + if ($range !== null) { + $min = $range->getMin() instanceof DateTimeInterface ? $range->getMin() : null; + $max = $range->getMax() instanceof DateTimeInterface ? $range->getMax() : null; + + if ($min !== null || $max !== null) { + $criteria[] = new Criterion\DateCreated($min, $max); + } + } + + return new NotificationQuery($criteria); } /** - * @return \Symfony\Component\HttpFoundation\JsonResponse + * @param \Pagerfanta\Pagerfanta<\Ibexa\Contracts\Core\Repository\Values\Notification\Notification> $pagerfanta */ - public function countNotificationsAction(): JsonResponse + private function createNotificationSelectionData(Pagerfanta $pagerfanta): NotificationSelectionData { - $response = new JsonResponse(); + $notifications = []; - try { - $response->setData([ - 'pending' => $this->notificationService->getPendingNotificationCount(), - 'total' => $this->notificationService->getNotificationCount(), - ]); - } catch (\Exception $exception) { - $response->setData([ - 'status' => 'failed', - 'error' => $exception->getMessage(), - ]); + foreach ($pagerfanta->getCurrentPageResults() as $notification) { + $notifications[$notification->id] = false; } - return $response; + return new NotificationSelectionData($notifications); + } + + public function countNotificationsAction(): JsonResponse + { + return $this->handleJsonErrors(fn () => new JsonResponse([ + 'pending' => $this->notificationService->getPendingNotificationCount(), + 'total' => $this->notificationService->getNotificationCount(), + ])); } /** * We're not able to establish two-way stream (it requires additional * server service for websocket connection), so * we need a way to mark notification * as read. AJAX call is fine. - * - * @param \Symfony\Component\HttpFoundation\Request $request - * @param mixed $notificationId - * - * @return \Symfony\Component\HttpFoundation\JsonResponse */ - public function markNotificationAsReadAction(Request $request, $notificationId): JsonResponse + public function markNotificationAsReadAction(Request $request, int $notificationId): JsonResponse { - $response = new JsonResponse(); - - try { - $notification = $this->notificationService->getNotification((int)$notificationId); + return $this->handleJsonErrors(function () use ($notificationId) { + $notification = $this->notificationService->getNotification($notificationId); $this->notificationService->markNotificationAsRead($notification); @@ -164,17 +210,97 @@ public function markNotificationAsReadAction(Request $request, $notificationId): } } - $response->setData($data); - } catch (\Exception $exception) { - $response->setData([ - 'status' => 'failed', - 'error' => $exception->getMessage(), + return new JsonResponse($data); + }); + } + + public function markNotificationsAsReadAction(Request $request): JsonResponse + { + return $this->handleJsonErrors(function () use ($request) { + $ids = $request->toArray()['ids'] ?? []; + + if (empty($ids)) { + throw new InvalidArgumentException('Missing or invalid "ids" parameter.'); + } + + $this->notificationService->markUserNotificationsAsRead($ids); + + return new JsonResponse([ + 'status' => 'success', + 'redirect' => $this->generateUrl('ibexa.notifications.render.all'), ]); + }); + } - $response->setStatusCode(404); + public function markAllNotificationsAsReadAction(Request $request): JsonResponse + { + return $this->handleJsonErrors(function () { + $this->notificationService->markUserNotificationsAsRead(); + + return new JsonResponse(['status' => 'success']); + }); + } + + public function markNotificationAsUnreadAction(Request $request, int $notificationId): JsonResponse + { + return $this->handleJsonErrors(function () use ($notificationId) { + $notification = $this->notificationService->getNotification($notificationId); + + $this->notificationService->markNotificationAsUnread($notification); + + return new JsonResponse(['status' => 'success']); + }); + } + + public function deleteNotificationAction(Request $request, int $notificationId): JsonResponse + { + return $this->handleJsonErrors(function () use ($notificationId) { + $notification = $this->notificationService->getNotification($notificationId); + + $this->notificationService->deleteNotification($notification); + + return new JsonResponse(['status' => 'success']); + }); + } + + public function deleteNotificationsAction(Request $request): Response + { + $form = $this->formFactory->deleteNotification(); + $form->handleRequest($request); + + if (!$form->isSubmitted()) { + return $this->redirectToRoute('ibexa.notifications.render.all'); + } + + if ($form->isValid()) { + $result = $this->submitHandler->handle( + $form, + function (NotificationSelectionData $data): Response { + return $this->processDeleteNotifications($data); + } + ); + + return $result ?? $this->redirectToRoute('ibexa.notifications.render.all'); + } + + $this->notificationHandler->error( + /** @Desc("An unexpected error occurred while deleting notifications.") */ + 'error.unexpected_delete_notifications', + [], + 'ibexa_notifications' + ); + + return $this->redirectToRoute('ibexa.notifications.render.all'); + } + + private function processDeleteNotifications(NotificationSelectionData $data): RedirectResponse + { + foreach (array_keys($data->getNotifications()) as $id) { + $notification = $this->notificationService->getNotification((int)$id); + $this->notificationService->deleteNotification($notification); } - return $response; + return $this->redirectToRoute('ibexa.notifications.render.all'); } } diff --git a/src/bundle/Form/Data/SearchQueryData.php b/src/bundle/Form/Data/SearchQueryData.php new file mode 100644 index 0000000000..0e31ef83c3 --- /dev/null +++ b/src/bundle/Form/Data/SearchQueryData.php @@ -0,0 +1,57 @@ +statuses; + } + + /** + * @param string[] $statuses + */ + public function setStatuses(array $statuses): void + { + $this->statuses = $statuses; + } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(?string $type): void + { + $this->type = $type; + } + + public function getCreatedRange(): ?DateRangeData + { + return $this->createdRange; + } + + public function setCreatedRange(?DateRangeData $createdRange): void + { + $this->createdRange = $createdRange; + } +} diff --git a/src/bundle/Resources/config/ezplatform_default_settings.yaml b/src/bundle/Resources/config/ezplatform_default_settings.yaml index 7c6cbb3174..b3ca07df9e 100644 --- a/src/bundle/Resources/config/ezplatform_default_settings.yaml +++ b/src/bundle/Resources/config/ezplatform_default_settings.yaml @@ -22,7 +22,7 @@ parameters: ibexa.site_access.config.admin_group.pagination.content_role_limit: 5 ibexa.site_access.config.admin_group.pagination.content_policy_limit: 5 ibexa.site_access.config.admin_group.pagination.bookmark_limit: 10 - ibexa.site_access.config.admin_group.pagination.notification_limit: 5 + ibexa.site_access.config.admin_group.pagination.notification_limit: 10 ibexa.site_access.config.admin_group.pagination.user_settings_limit: 10 ibexa.site_access.config.admin_group.pagination.content_draft_limit: 10 ibexa.site_access.config.admin_group.pagination.location_limit: 10 diff --git a/src/bundle/Resources/config/routing.yaml b/src/bundle/Resources/config/routing.yaml index cc9fd2b3ca..26f9fb7759 100644 --- a/src/bundle/Resources/config/routing.yaml +++ b/src/bundle/Resources/config/routing.yaml @@ -912,6 +912,15 @@ ibexa.notifications.render.page: requirements: page: '\d+' +ibexa.notifications.render.all: + path: /notifications/render/all/{page} + defaults: + _controller: 'Ibexa\Bundle\AdminUi\Controller\AllNotificationsController::renderAllNotificationsPageAction' + page: 1 + methods: [ GET, POST ] + requirements: + page: '\d+' + ibexa.notifications.count: path: /notifications/count defaults: @@ -920,12 +929,50 @@ ibexa.notifications.count: ibexa.notifications.mark_as_read: path: /notification/read/{notificationId} + options: + expose: true defaults: _controller: 'Ibexa\Bundle\AdminUi\Controller\NotificationController::markNotificationAsReadAction' methods: [GET] requirements: notificationId: '\d+' +ibexa.notifications.mark_multiple_as_read: + path: /notifications/read + options: + expose: true + controller: + 'Ibexa\Bundle\AdminUi\Controller\NotificationController::markNotificationsAsReadAction' + methods: [POST] + +ibexa.notifications.mark_all_as_read: + path: /notifications/read-all + options: + expose: true + defaults: + _controller: 'Ibexa\Bundle\AdminUi\Controller\NotificationController::markAllNotificationsAsReadAction' + methods: [GET] + +ibexa.notifications.mark_as_unread: + path: /notification/unread/{notificationId} + options: + expose: true + defaults: + _controller: 'Ibexa\Bundle\AdminUi\Controller\NotificationController::markNotificationAsUnreadAction' + methods: [GET] + requirements: + notificationId: '\d+' + +ibexa.notifications.delete: + path: /notification/delete/{notificationId} + options: + expose: true + defaults: + _controller: 'Ibexa\Bundle\AdminUi\Controller\NotificationController::deleteNotificationAction' + methods: [POST] + requirements: + notificationId: '\d+' + ibexa.asset.upload_image: path: /asset/image options: @@ -934,6 +981,12 @@ ibexa.asset.upload_image: _controller: 'Ibexa\Bundle\AdminUi\Controller\AssetController::uploadImageAction' methods: [POST] +ibexa.notifications.delete_multiple: + path: /notification/delete-multiple + defaults: + _controller: 'Ibexa\Bundle\AdminUi\Controller\NotificationController::deleteNotificationsAction' + methods: [POST] + # # Permissions # @@ -964,7 +1017,6 @@ ibexa.permission.limitation.language.content_read: requirements: contentInfoId: \d+ - ### Focus Mode ibexa.focus_mode.change: diff --git a/src/bundle/Resources/config/services/controllers.yaml b/src/bundle/Resources/config/services/controllers.yaml index c80448447a..d1c49d8a0f 100644 --- a/src/bundle/Resources/config/services/controllers.yaml +++ b/src/bundle/Resources/config/services/controllers.yaml @@ -104,6 +104,12 @@ services: tags: - controller.service_arguments + Ibexa\Bundle\AdminUi\Controller\AllNotificationsController: + parent: Ibexa\Contracts\AdminUi\Controller\Controller + autowire: true + tags: + - controller.service_arguments + Ibexa\Bundle\AdminUi\Controller\ObjectStateController: parent: Ibexa\Contracts\AdminUi\Controller\Controller autowire: true diff --git a/src/bundle/Resources/config/services/forms.yaml b/src/bundle/Resources/config/services/forms.yaml index f5ad33e4ce..781358a52a 100644 --- a/src/bundle/Resources/config/services/forms.yaml +++ b/src/bundle/Resources/config/services/forms.yaml @@ -293,6 +293,10 @@ services: Ibexa\AdminUi\Form\Type\Search\TrashSearchType: ~ + Ibexa\AdminUi\Form\Type\Notification\SearchType: ~ + + Ibexa\AdminUi\Form\Type\Notification\NotificationTypeChoiceType: ~ + Ibexa\AdminUi\Form\Type\Section\SectionChoiceType: ~ Ibexa\AdminUi\Form\Type\Section\SectionContentAssignType: ~ diff --git a/src/bundle/Resources/encore/ibexa.js.config.js b/src/bundle/Resources/encore/ibexa.js.config.js index bf42ec470b..c7c09b3e1f 100644 --- a/src/bundle/Resources/encore/ibexa.js.config.js +++ b/src/bundle/Resources/encore/ibexa.js.config.js @@ -39,6 +39,7 @@ const layout = [ path.resolve(__dirname, '../public/js/scripts/admin.prevent.click.js'), path.resolve(__dirname, '../public/js/scripts/admin.picker.js'), path.resolve(__dirname, '../public/js/scripts/admin.notifications.modal.js'), + path.resolve(__dirname, '../public/js/scripts/sidebar/side.panel.js'), path.resolve(__dirname, '../public/js/scripts/admin.location.add.translation.js'), path.resolve(__dirname, '../public/js/scripts/admin.form.autosubmit.js'), path.resolve(__dirname, '../public/js/scripts/admin.anchor.navigation'), @@ -254,5 +255,10 @@ module.exports = (Encore) => { path.resolve(__dirname, '../public/js/scripts/admin.location.tab.js'), path.resolve(__dirname, '../public/js/scripts/admin.location.adaptive.tabs.js'), ]) - .addEntry('ibexa-admin-ui-edit-base-js', [path.resolve(__dirname, '../public/js/scripts/edit.header.js')]); + .addEntry('ibexa-admin-ui-edit-base-js', [path.resolve(__dirname, '../public/js/scripts/edit.header.js')]) + .addEntry('ibexa-admin-notifications-list-js', [ + path.resolve(__dirname, '../public/js/scripts/admin.notifications.filters.sidebar.js'), + path.resolve(__dirname, '../public/js/scripts/admin.notifications.list.js'), + path.resolve(__dirname, '../public/js/scripts/admin.notifications.filters.js'), + ]); }; diff --git a/src/bundle/Resources/public/js/scripts/admin.multilevel.popup.menu.js b/src/bundle/Resources/public/js/scripts/admin.multilevel.popup.menu.js index 9093770d6f..998cad485a 100644 --- a/src/bundle/Resources/public/js/scripts/admin.multilevel.popup.menu.js +++ b/src/bundle/Resources/public/js/scripts/admin.multilevel.popup.menu.js @@ -1,15 +1,29 @@ (function (global, doc, ibexa) { - const multilevelPopupMenusContainers = doc.querySelectorAll( - '.ibexa-multilevel-popup-menu:not(.ibexa-multilevel-popup-menu--custom-init)', - ); + const initMultilevelPopupMenus = (container) => { + const multilevelPopupMenusContainers = container.querySelectorAll( + '.ibexa-multilevel-popup-menu:not(.ibexa-multilevel-popup-menu--custom-init)', + ); + + multilevelPopupMenusContainers.forEach((multilevelPopupMenusContainer) => { + const multilevelPopupMenu = new ibexa.core.MultilevelPopupMenu({ + container: multilevelPopupMenusContainer, + triggerElement: doc.querySelector(multilevelPopupMenusContainer.dataset.triggerElementSelector), + initialBranchPlacement: multilevelPopupMenusContainer.dataset.initialBranchPlacement, + }); - multilevelPopupMenusContainers.forEach((container) => { - const multilevelPopupMenu = new ibexa.core.MultilevelPopupMenu({ - container, - triggerElement: doc.querySelector(container.dataset.triggerElementSelector), - initialBranchPlacement: container.dataset.initialBranchPlacement, + multilevelPopupMenu.init(); }); + }; + + initMultilevelPopupMenus(doc); - multilevelPopupMenu.init(); - }); + doc.body.addEventListener( + 'ibexa-multilevel-popup-menu:init', + (event) => { + const { container } = event.detail; + + initMultilevelPopupMenus(container); + }, + false, + ); })(window, window.document, window.ibexa); diff --git a/src/bundle/Resources/public/js/scripts/admin.notifications.filters.js b/src/bundle/Resources/public/js/scripts/admin.notifications.filters.js new file mode 100644 index 0000000000..c15183bc13 --- /dev/null +++ b/src/bundle/Resources/public/js/scripts/admin.notifications.filters.js @@ -0,0 +1,134 @@ +(function (global, doc) { + const searchForm = doc.querySelector('.ibexa-list-search-form'); + const filtersContainerNode = doc.querySelector('.ibexa-list-filters'); + const applyFiltersBtn = filtersContainerNode.querySelector('.ibexa-btn--apply'); + const clearFiltersBtn = filtersContainerNode.querySelector('.ibexa-btn--clear'); + const statusFilterNode = filtersContainerNode.querySelector('.ibexa-list-filters__item--statuses'); + const typeFilterNode = filtersContainerNode.querySelector('.ibexa-list-filters__item--type'); + const datetimeFilterNodes = filtersContainerNode.querySelectorAll('.ibexa-list-filters__item--date-time .ibexa-picker'); + + const clearFilter = (filterNode) => { + if (!filterNode) { + return; + } + + const sourceSelect = filterNode.querySelector('.ibexa-list-filters__item-content .ibexa-dropdown__source .ibexa-input--select'); + const checkboxes = filterNode.querySelectorAll( + '.ibexa-list-filters__item-content .ibexa-input--checkbox:not([name="dropdown-checkbox"])', + ); + const timePicker = filterNode.querySelector('.ibexa-date-time-picker__input'); + + if (sourceSelect) { + const sourceSelectOptions = sourceSelect.querySelectorAll('option'); + sourceSelectOptions.forEach((option) => { + option.selected = false; + }); + + if (isTimeFilterNode(filterNode)) { + sourceSelectOptions[0].selected = true; + } + } else if (checkboxes.length) { + checkboxes.forEach((checkbox) => (checkbox.checked = false)); + } else if (timePicker.value.length) { + const formInput = filterNode.querySelector('.ibexa-picker__form-input'); + + timePicker.value = ''; + formInput.value = ''; + + timePicker.dispatchEvent(new Event('input')); + formInput.dispatchEvent(new Event('input')); + } + + searchForm.submit(); + }; + const attachStatusFilterEvents = (filterNode) => { + if (!filterNode) { + return; + } + + const checkboxes = filterNode.querySelectorAll( + '.ibexa-list-filters__item-content .ibexa-input--checkbox:not([name="dropdown-checkbox"])', + ); + checkboxes.forEach((checkbox) => { + checkbox.addEventListener('change', filterChange, false); + }); + }; + const attachTypeFilterEvents = (filterNode) => { + if (!filterNode) { + return; + } + + const sourceSelect = filterNode.querySelector('.ibexa-list-filters__item-content .ibexa-dropdown__source .ibexa-input--select'); + sourceSelect?.addEventListener('change', filterChange, false); + }; + const attachDateFilterEvents = (filterNode) => { + if (!filterNode) { + return; + } + + const picker = filterNode.querySelector('.ibexa-input--date'); + picker?.addEventListener('change', dateFilterChange, false); + }; + + const isTimeFilterNode = (filterNode) => { + return filterNode.classList.contains('ibexa-picker'); + }; + const hasFilterValue = (filterNode) => { + if (!filterNode) { + return; + } + + const select = filterNode.querySelector('.ibexa-dropdown__source .ibexa-input--select'); + const checkedCheckboxes = filterNode.querySelectorAll('.ibexa-input--checkbox:checked'); + + if (isTimeFilterNode(filterNode)) { + const timePicker = filterNode.querySelector('.ibexa-date-time-picker__input'); + + return !!timePicker.dataset.timestamp; + } + + return !!(select?.value || checkedCheckboxes?.length); + }; + const isSomeFilterSet = () => { + const hasStatusFilterValue = hasFilterValue(statusFilterNode); + const hasTypeFilterValue = hasFilterValue(typeFilterNode); + const hasDatetimeFilterValue = [...datetimeFilterNodes].some(hasFilterValue); + + return hasStatusFilterValue || hasTypeFilterValue || hasDatetimeFilterValue; + }; + const attachInitEvents = () => { + attachStatusFilterEvents(statusFilterNode); + attachTypeFilterEvents(typeFilterNode); + datetimeFilterNodes.forEach((input) => attachDateFilterEvents(input)); + }; + const dateFilterChange = (event) => { + const dateInput = event.target; + + if (dateInput.classList.contains('is-invalid')) { + dateInput.classList.remove('is-invalid'); + + const errorWrapper = dateInput.closest('.form-group').querySelector('.ibexa-form-error'); + + if (errorWrapper) { + errorWrapper.innerText = ''; + } + } + + filterChange(); + }; + const filterChange = () => { + const hasFiltersSetValue = isSomeFilterSet(); + + applyFiltersBtn.disabled = false; + clearFiltersBtn.disabled = !hasFiltersSetValue; + }; + const clearAllFilters = () => { + clearFilter(statusFilterNode); + clearFilter(typeFilterNode); + datetimeFilterNodes.forEach((input) => clearFilter(input)); + }; + + attachInitEvents(); + + clearFiltersBtn.addEventListener('click', clearAllFilters, false); +})(window, window.document); diff --git a/src/bundle/Resources/public/js/scripts/admin.notifications.filters.sidebar.js b/src/bundle/Resources/public/js/scripts/admin.notifications.filters.sidebar.js new file mode 100644 index 0000000000..2bd689df8d --- /dev/null +++ b/src/bundle/Resources/public/js/scripts/admin.notifications.filters.sidebar.js @@ -0,0 +1,17 @@ +(function (global, doc) { + const sidebar = doc.querySelector('.ibexa-list-filters__sidebar'); + const toggleBtn = sidebar.querySelector('.ibexa-list-filters__expand-btn'); + const toggleCollapseIcon = toggleBtn.querySelector('.ibexa-list-filters__collapse-icon'); + const toggleExpandIcon = toggleBtn.querySelector('.ibexa-list-filters__expand-icon'); + + const toggleSidebar = () => { + const isExpanded = toggleBtn.getAttribute('aria-expanded') === 'true'; + + sidebar.classList.toggle('ibexa-list-filters__sidebar--collapsed', isExpanded); + toggleBtn.setAttribute('aria-expanded', (!isExpanded).toString()); + toggleExpandIcon.toggleAttribute('hidden', !isExpanded); + toggleCollapseIcon.toggleAttribute('hidden', isExpanded); + }; + + toggleBtn.addEventListener('click', toggleSidebar, false); +})(window, window.document); diff --git a/src/bundle/Resources/public/js/scripts/admin.notifications.list.js b/src/bundle/Resources/public/js/scripts/admin.notifications.list.js new file mode 100644 index 0000000000..1b22581839 --- /dev/null +++ b/src/bundle/Resources/public/js/scripts/admin.notifications.list.js @@ -0,0 +1,188 @@ +(function (global, doc, ibexa, Translator, Routing) { + const SELECTOR_MODAL_ITEM = '.ibexa-notifications-modal__item'; + const SELECTOR_GO_TO_NOTIFICATION = '.ibexa-notification-view-all__show'; + const SELECTOR_TOGGLE_NOTIFICATION = '.ibexa-notification-view-all__mail'; + const { showErrorNotification } = ibexa.helpers.notification; + const { getJsonFromResponse } = ibexa.helpers.request; + const markAllAsReadBtn = doc.querySelector('.ibexa-notification-list__mark-all-as-read'); + const markAsReadBtn = doc.querySelector('.ibexa-notification-list__btn-mark-as-read'); + const deleteBtn = doc.querySelector('.ibexa-notification-list__btn-delete'); + const notificationsCheckboxes = [ + ...doc.querySelectorAll('.ibexa-notification-list .ibexa-table__cell--has-checkbox .ibexa-input--checkbox'), + ]; + const markAllAsRead = () => { + const markAllAsReadLink = Routing.generate('ibexa.notifications.mark_all_as_read'); + const message = Translator.trans( + /* @Desc("Cannot mark all notifications as read") */ 'notifications.modal.message.error.mark_all_as_read', + {}, + 'ibexa_notifications', + ); + + fetch(markAllAsReadLink, { mode: 'same-origin', credentials: 'same-origin' }) + .then(getJsonFromResponse) + .then((response) => { + if (response.status === 'success') { + global.location.reload(); + } else { + showErrorNotification(message); + } + }) + .catch(() => { + showErrorNotification(message); + }); + }; + + const markSelectedAsRead = () => { + const selectedNotifications = [...notificationsCheckboxes] + .filter((checkbox) => checkbox.checked) + .map((checkbox) => checkbox.dataset.notificationId); + + const markAsReadLink = Routing.generate('ibexa.notifications.mark_multiple_as_read'); + const request = new Request(markAsReadLink, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + mode: 'same-origin', + credentials: 'same-origin', + body: JSON.stringify({ + ids: selectedNotifications, + }), + }); + const message = Translator.trans( + /* @Desc("Cannot mark selected notifications as read") */ + 'notifications.modal.message.error.mark_selected_as_read', + {}, + 'ibexa_notifications', + ); + + fetch(request) + .then(getJsonFromResponse) + .then((response) => { + if (response.status === 'success') { + global.location.reload(); + } else { + showErrorNotification(message); + } + }) + .catch(() => { + showErrorNotification(message); + }); + }; + + const handleNotificationClick = (notification, isToggle = false) => { + const notificationRow = notification.closest('.ibexa-table__row'); + const isRead = notification.classList.contains('ibexa-notifications-modal__item--read'); + const notificationReadLink = + isToggle && isRead ? notificationRow.dataset.notificationUnread : notificationRow.dataset.notificationRead; + const request = new Request(notificationReadLink, { + mode: 'cors', + credentials: 'same-origin', + }); + + fetch(request) + .then(getJsonFromResponse) + .then((response) => { + if (response.status === 'success') { + notification.classList.toggle('ibexa-notifications-modal__item--read', !isRead); + + if (isToggle) { + notification + .querySelector('.ibexa-table__cell .ibexa-notification-view-all__mail-open') + ?.classList.toggle('ibexa-notification-view-all__icon-hidden'); + notification + .querySelector('.ibexa-table__cell .ibexa-notification-view-all__mail-closed') + ?.classList.toggle('ibexa-notification-view-all__icon-hidden'); + + const statusText = isRead + ? Translator.trans(/*@Desc("Unread")*/ 'notification.unread', {}, 'ibexa_notifications') + : Translator.trans(/*@Desc("Read")*/ 'notification.read', {}, 'ibexa_notifications'); + + notificationRow.querySelectorAll('.ibexa-notification-view-all__notice-dot').forEach((noticeDot) => { + noticeDot.setAttribute('data-is-read', (!isRead).toString()); + }); + notificationRow.querySelector('.ibexa-notification-view-all__read').innerHTML = statusText; + + return; + } + + if (!isToggle && response.redirect) { + global.location = response.redirect; + } + } else { + const message = Translator.trans( + /* @Desc("Cannot update this notification") */ + 'notifications.modal.message.error.update', + {}, + 'ibexa_notifications', + ); + + showErrorNotification(message); + } + }) + .catch(showErrorNotification); + }; + + const handleNotificationActionClick = (event, isToggle = false) => { + const notification = event.target.closest(SELECTOR_MODAL_ITEM); + + if (!notification) { + return; + } + + handleNotificationClick(notification, isToggle); + }; + const init = () => { + doc.querySelector('.ibexa-notifications-modal').dataset.closeReload = 'true'; + + doc.querySelectorAll(SELECTOR_MODAL_ITEM).forEach((item) => { + const isRead = item.classList.contains('ibexa-notifications-modal__item--read'); + + item.querySelector(`.ibexa-table__cell .ibexa-notification-view-all__mail-closed`)?.classList.toggle( + 'ibexa-notification-view-all__icon-hidden', + !isRead, + ); + item.querySelector(`.ibexa-table__cell .ibexa-notification-view-all__mail-open`)?.classList.toggle( + 'ibexa-notification-view-all__icon-hidden', + isRead, + ); + }, false); + }; + + init(); + + doc.querySelectorAll(SELECTOR_GO_TO_NOTIFICATION).forEach((link) => + link.addEventListener('click', handleNotificationActionClick, false), + ); + doc.querySelectorAll(SELECTOR_TOGGLE_NOTIFICATION).forEach((link) => + link.addEventListener('click', (event) => handleNotificationActionClick(event, true), false), + ); + markAllAsReadBtn.addEventListener('click', markAllAsRead, false); + markAsReadBtn.addEventListener('click', markSelectedAsRead, false); + + const toggleActionButtonState = () => { + const checkedNotifications = notificationsCheckboxes.filter((el) => el.checked); + const isAnythingSelected = checkedNotifications.length > 0; + + deleteBtn.disabled = !isAnythingSelected; + markAsReadBtn.disabled = + !isAnythingSelected || + !checkedNotifications.some( + (checkbox) => + checkbox.closest('.ibexa-table__row').querySelector('.ibexa-notification-view-all__notice-dot').dataset.isRead === + 'false', + ); + }; + const handleCheckboxChange = (checkbox) => { + const checkboxFormId = checkbox.dataset?.formCheckboxId; + const formRemoveCheckbox = doc.querySelector(`[data-toggle-button-id="#confirm-selection_remove"] input#${checkboxFormId}`); + + if (formRemoveCheckbox) { + formRemoveCheckbox.checked = checkbox.checked; + } + + toggleActionButtonState(); + }; + + notificationsCheckboxes.forEach((checkbox) => checkbox.addEventListener('change', () => handleCheckboxChange(checkbox), false)); +})(window, window.document, window.ibexa, window.Translator, window.Routing); diff --git a/src/bundle/Resources/public/js/scripts/admin.notifications.modal.js b/src/bundle/Resources/public/js/scripts/admin.notifications.modal.js index efdaa755b6..6afd4d2c1f 100644 --- a/src/bundle/Resources/public/js/scripts/admin.notifications.modal.js +++ b/src/bundle/Resources/public/js/scripts/admin.notifications.modal.js @@ -1,25 +1,19 @@ -(function (global, doc, ibexa, Translator) { +(function (global, doc, ibexa, Translator, Routing) { let currentPageLink = null; let getNotificationsStatusErrorShowed = false; let lastFailedCountFetchNotificationNode = null; const SELECTOR_MODAL_ITEM = '.ibexa-notifications-modal__item'; - const SELECTOR_MODAL_RESULTS = '.ibexa-notifications-modal__results'; - const SELECTOR_MODAL_TITLE = '.modal-title'; - const SELECTOR_DESC_TEXT = '.description__text'; - const SELECTOR_TABLE = '.ibexa-table--notifications'; - const CLASS_ELLIPSIS = 'description__text--ellipsis'; - const CLASS_PAGINATION_LINK = 'page-link'; + const SELECTOR_MODAL_RESULTS = '.ibexa-notifications-modal__results .ibexa-scrollable-wrapper'; + const SELECTOR_MODAL_TITLE = '.ibexa-side-panel__header'; + const SELECTOR_LIST = '.ibexa-list--notifications'; const CLASS_MODAL_LOADING = 'ibexa-notifications-modal--loading'; const INTERVAL = 30000; - const modal = doc.querySelector('.ibexa-notifications-modal'); + const panel = doc.querySelector('.ibexa-notifications-modal'); const { showErrorNotification, showWarningNotification } = ibexa.helpers.notification; const { getJsonFromResponse, getTextFromResponse } = ibexa.helpers.request; - const markAsRead = (notification, response) => { - if (response.status === 'success') { + const handleNotificationClickRequest = (notification, response) => { + if (response.status === 'success' && response.redirect) { notification.classList.add('ibexa-notifications-modal__item--read'); - } - - if (response.redirect) { global.location = response.redirect; } }; @@ -30,25 +24,11 @@ credentials: 'same-origin', }); - fetch(request).then(getJsonFromResponse).then(markAsRead.bind(null, notification)).catch(showErrorNotification); + fetch(request).then(getJsonFromResponse).then(handleNotificationClickRequest.bind(null, notification)).catch(showErrorNotification); }; - const handleTableClick = (event) => { - if (event.target.classList.contains('description__read-more')) { - event.target.closest(SELECTOR_MODAL_ITEM).querySelector(SELECTOR_DESC_TEXT).classList.remove(CLASS_ELLIPSIS); - - return; - } - - const notification = event.target.closest(SELECTOR_MODAL_ITEM); - if (!notification) { - return; - } - - handleNotificationClick(notification); - }; const getNotificationsStatus = () => { - const notificationsTable = modal.querySelector(SELECTOR_TABLE); + const notificationsTable = panel.querySelector(SELECTOR_LIST); const notificationsStatusLink = notificationsTable.dataset.notificationsCount; const request = new Request(notificationsStatusLink, { mode: 'cors', @@ -67,12 +47,6 @@ }) .catch(onGetNotificationsStatusFailure); }; - - /** - * Handle a failure while getting notifications status - * - * @method onGetNotificationsStatusFailure - */ const onGetNotificationsStatusFailure = (error) => { if (lastFailedCountFetchNotificationNode && doc.contains(lastFailedCountFetchNotificationNode)) { return; @@ -93,19 +67,27 @@ getNotificationsStatusErrorShowed = true; }; const updateModalTitleTotalInfo = (notificationsCount) => { - const modalTitle = modal.querySelector(SELECTOR_MODAL_TITLE); + const modalTitle = panel.querySelector(SELECTOR_MODAL_TITLE); + const modalFooter = panel.querySelector('.ibexa-notifications-modal__view-all-btn--count'); + modalFooter.textContent = ` (${notificationsCount})`; modalTitle.dataset.notificationsTotal = `(${notificationsCount})`; + markAllAsReadBtn.disabled = notificationsCount === 0; + + if (notificationsCount < 10) { + panel.querySelector('.ibexa-notifications-modal__count').textContent = `(${notificationsCount})`; + } }; const updatePendingNotificationsView = (notificationsInfo) => { const noticeDot = doc.querySelector('.ibexa-header-user-menu__notice-dot'); + noticeDot.dataset.count = notificationsInfo.pending; noticeDot.classList.toggle('ibexa-header-user-menu__notice-dot--no-notice', notificationsInfo.pending === 0); }; const setPendingNotificationCount = (notificationsInfo) => { updatePendingNotificationsView(notificationsInfo); - const notificationsTable = modal.querySelector(SELECTOR_TABLE); + const notificationsTable = panel.querySelector(SELECTOR_LIST); const notificationsTotal = notificationsInfo.total; const notificationsTotalOld = parseInt(notificationsTable.dataset.notificationsTotal, 10); @@ -115,14 +97,174 @@ fetchNotificationPage(currentPageLink); } }; + const markAllAsRead = () => { + const markAllAsReadLink = Routing.generate('ibexa.notifications.mark_all_as_read'); + const message = Translator.trans( + /* @Desc("Cannot mark all notifications as read") */ 'notifications.modal.message.error.mark_all_as_read', + {}, + 'ibexa_notifications', + ); + + fetch(markAllAsReadLink, { mode: 'same-origin', credentials: 'same-origin' }) + .then(getJsonFromResponse) + .then((response) => { + if (response.status === 'success') { + const allUnreadNotifications = doc.querySelectorAll('.ibexa-notifications-modal__item'); + + allUnreadNotifications.forEach((notification) => notification.classList.add('ibexa-notifications-modal__item--read')); + getNotificationsStatus(); + const actions = doc.querySelectorAll('.ibexa-notifications-modal--mark-as-read'); + const markAsUnreadLabel = Translator.trans( + /* @Desc("Mark as unread") */ 'notification.mark_as_unread', + {}, + 'ibexa_notifications', + ); + + actions.forEach((notification) => { + notification.classList.remove('ibexa-notifications-modal--mark-as-read'); + notification.classList.add('ibexa-notifications-modal--mark-as-unread'); + notification.textContent = markAsUnreadLabel; + }); + } else { + showErrorNotification(message); + } + }) + .catch(() => { + showErrorNotification(message); + }); + }; + const markAsRead = ({ currentTarget }) => { + const { notificationId } = currentTarget.dataset; + const markAsReadLink = Routing.generate('ibexa.notifications.mark_as_read', { notificationId }); + const message = Translator.trans( + /* @Desc("Cannot mark notification as read") */ 'notifications.modal.message.error.mark_as_read', + {}, + 'ibexa_notifications', + ); + + fetch(markAsReadLink, { mode: 'same-origin', credentials: 'same-origin' }) + .then(getJsonFromResponse) + .then((response) => { + if (response.status === 'success') { + const notification = doc.querySelector(`.ibexa-notifications-modal__item[data-notification-id="${notificationId}"]`); + const menuBranch = currentTarget.closest('.ibexa-multilevel-popup-menu__branch'); + const menuInstance = ibexa.helpers.objectInstances.getInstance(menuBranch.menuInstanceElement); + const markAsUnreadLabel = Translator.trans( + /* @Desc("Mark as unread") */ 'notification.mark_as_unread', + {}, + 'ibexa_notifications', + ); + menuInstance.closeMenu(); + notification.classList.add('ibexa-notifications-modal__item--read'); + currentTarget.classList.remove('ibexa-notifications-modal--mark-as-read'); + currentTarget.classList.add('ibexa-notifications-modal--mark-as-unread'); + currentTarget.textContent = markAsUnreadLabel; + getNotificationsStatus(); + } else { + showErrorNotification(message); + } + }) + .catch(() => { + showErrorNotification(message); + }); + }; + const markAsUnread = ({ currentTarget }) => { + const { notificationId } = currentTarget.dataset; + const markAsUnreadLink = Routing.generate('ibexa.notifications.mark_as_unread', { notificationId }); + const message = Translator.trans( + /* @Desc("Cannot mark notification as unread") */ 'notifications.modal.message.error.mark_as_unread', + {}, + 'ibexa_notifications', + ); + + fetch(markAsUnreadLink, { mode: 'same-origin', credentials: 'same-origin' }) + .then(getJsonFromResponse) + .then((response) => { + if (response.status === 'success') { + const notification = doc.querySelector(`.ibexa-notifications-modal__item[data-notification-id="${notificationId}"]`); + const menuBranch = currentTarget.closest('.ibexa-multilevel-popup-menu__branch'); + const menuInstance = ibexa.helpers.objectInstances.getInstance(menuBranch.menuInstanceElement); + const markAsReadLabel = Translator.trans( + /* @Desc("Mark as read") */ 'notification.mark_as_read', + {}, + 'ibexa_notifications', + ); + + menuInstance.closeMenu(); + notification.classList.remove('ibexa-notifications-modal__item--read'); + currentTarget.classList.remove('ibexa-notifications-modal--mark-as-unread'); + currentTarget.classList.add('ibexa-notifications-modal--mark-as-read'); + currentTarget.textContent = markAsReadLabel; + getNotificationsStatus(); + } else { + showErrorNotification(message); + } + }) + .catch(() => { + showErrorNotification(message); + }); + }; + const handleMarkAsAction = ({ currentTarget }) => { + const markAsReadLabel = Translator.trans(/* @Desc("Mark as read") */ 'notification.mark_as_read', {}, 'ibexa_notifications'); + + currentTarget.textContent.trim() === markAsReadLabel ? markAsRead({ currentTarget }) : markAsUnread({ currentTarget }); + }; + const deleteNotification = ({ currentTarget }) => { + const { notificationId } = currentTarget.dataset; + const deleteLink = Routing.generate('ibexa.notifications.delete', { notificationId }); + const message = Translator.trans( + /* @Desc("Cannot delete notification") */ 'notifications.modal.message.error.delete', + {}, + 'ibexa_notifications', + ); + + fetch(deleteLink, { method: 'POST', mode: 'same-origin', credentials: 'same-origin' }) + .then(getJsonFromResponse) + .then((response) => { + if (response.status === 'success') { + const notification = doc.querySelector(`.ibexa-notifications-modal__item[data-notification-id="${notificationId}"]`); + const menuBranch = currentTarget.closest('.ibexa-multilevel-popup-menu__branch'); + const menuInstance = ibexa.helpers.objectInstances.getInstance(menuBranch.menuInstanceElement); + + menuInstance.closeMenu(); + notification.remove(); + getNotificationsStatus(); + } else { + showErrorNotification(message); + } + }) + .catch(() => { + showErrorNotification(message); + }); + }; + const attachActionsListeners = () => { + const attachListener = (node, callback) => node.addEventListener('click', callback, false); + const markAsButtons = doc.querySelectorAll('.ibexa-notifications-modal--mark-as'); + const deleteButtons = doc.querySelectorAll('.ibexa-notifications-modal--delete'); + + markAsButtons.forEach((markAsButton) => { + attachListener(markAsButton, handleMarkAsAction); + }); + + deleteButtons.forEach((deleteButton) => { + attachListener(deleteButton, deleteNotification); + }); + }; const showNotificationPage = (pageHtml) => { - const modalResults = modal.querySelector(SELECTOR_MODAL_RESULTS); + const modalResults = panel.querySelector(SELECTOR_MODAL_RESULTS); modalResults.innerHTML = pageHtml; toggleLoading(false); + attachActionsListeners(); + + doc.body.dispatchEvent( + new CustomEvent('ibexa-multilevel-popup-menu:init', { + detail: { container: modalResults }, + }), + ); }; const toggleLoading = (show) => { - modal.classList.toggle(CLASS_MODAL_LOADING, show); + panel.classList.toggle(CLASS_MODAL_LOADING, show); }; const fetchNotificationPage = (link) => { if (!link) { @@ -143,32 +285,26 @@ fetch(request).then(getTextFromResponse).then(showNotificationPage).catch(showErrorNotification); }; const handleModalResultsClick = (event) => { - const isPaginationBtn = event.target.classList.contains(CLASS_PAGINATION_LINK); + const isActionBtn = event.target.closest('.ibexa-notifications-modal__actions'); + const notification = event.target.closest(SELECTOR_MODAL_ITEM); - if (isPaginationBtn) { - handleNotificationsPageChange(event); + if (isActionBtn || !notification) { return; } - handleTableClick(event); - }; - const handleNotificationsPageChange = (event) => { - event.preventDefault(); - - const notificationsPageLink = event.target.href; - - fetchNotificationPage(notificationsPageLink); + handleNotificationClick(notification); }; - if (!modal) { + if (!panel) { return; } - - const notificationsTable = modal.querySelector(SELECTOR_TABLE); + const markAllAsReadBtn = panel.querySelector('.ibexa-notifications-modal__mark-all-read-btn'); + const notificationsTable = panel.querySelector(SELECTOR_LIST); currentPageLink = notificationsTable.dataset.notifications; const interval = Number.parseInt(notificationsTable.dataset.notificationsCountInterval, 10) || INTERVAL; - modal.querySelectorAll(SELECTOR_MODAL_RESULTS).forEach((link) => link.addEventListener('click', handleModalResultsClick, false)); + panel.querySelectorAll(SELECTOR_MODAL_RESULTS).forEach((link) => link.addEventListener('click', handleModalResultsClick, false)); + markAllAsReadBtn.addEventListener('click', markAllAsRead, false); const getNotificationsStatusLoop = () => { getNotificationsStatus().finally(() => { @@ -177,4 +313,5 @@ }; getNotificationsStatusLoop(); -})(window, window.document, window.ibexa, window.Translator); + attachActionsListeners(); +})(window, window.document, window.ibexa, window.Translator, window.Routing); diff --git a/src/bundle/Resources/public/js/scripts/sidebar/side.panel.js b/src/bundle/Resources/public/js/scripts/sidebar/side.panel.js new file mode 100644 index 0000000000..08123d75b4 --- /dev/null +++ b/src/bundle/Resources/public/js/scripts/sidebar/side.panel.js @@ -0,0 +1,60 @@ +(function (global, doc, ibexa) { + const CLASS_HIDDEN = 'ibexa-side-panel--hidden'; + const sidePanelCloseBtns = doc.querySelectorAll( + '.ibexa-side-panel .ibexa-btn--close, .ibexa-side-panel .ibexa-side-panel__btn--cancel', + ); + const sidePanelTriggers = [...doc.querySelectorAll('.ibexa-side-panel-trigger')]; + const backdrop = new ibexa.core.Backdrop(); + const removeBackdrop = () => { + backdrop.hide(); + doc.body.classList.remove('ibexa-scroll-disabled'); + }; + const showBackdrop = () => { + backdrop.show(); + doc.body.classList.add('ibexa-scroll-disabled'); + }; + const toggleSidePanelVisibility = (sidePanel) => { + const shouldBeVisible = sidePanel.classList.contains(CLASS_HIDDEN); + const handleClickOutside = (event) => { + if (event.target.classList.contains('ibexa-backdrop')) { + sidePanel.classList.add(CLASS_HIDDEN); + doc.body.removeEventListener('click', handleClickOutside, false); + removeBackdrop(); + + if (sidePanel.dataset?.closeReload === 'true') { + global.location.reload(); + } + } + }; + + sidePanel.classList.toggle(CLASS_HIDDEN, !shouldBeVisible); + + if (shouldBeVisible) { + doc.body.addEventListener('click', handleClickOutside, false); + showBackdrop(); + } else { + doc.body.removeEventListener('click', handleClickOutside, false); + removeBackdrop(); + } + }; + + sidePanelTriggers.forEach((trigger) => { + trigger.addEventListener( + 'click', + (event) => { + toggleSidePanelVisibility(doc.querySelector(event.currentTarget.dataset.sidePanelSelector)); + }, + false, + ); + }); + + sidePanelCloseBtns.forEach((closeBtn) => + closeBtn.addEventListener( + 'click', + (event) => { + toggleSidePanelVisibility(event.currentTarget.closest('.ibexa-side-panel')); + }, + false, + ), + ); +})(window, window.document, window.ibexa); diff --git a/src/bundle/Resources/public/scss/_custom.scss b/src/bundle/Resources/public/scss/_custom.scss index bd0f6c6364..59569b7fe3 100644 --- a/src/bundle/Resources/public/scss/_custom.scss +++ b/src/bundle/Resources/public/scss/_custom.scss @@ -381,6 +381,7 @@ $ibexa-text-font-size-large: calculateRem(18px); $ibexa-text-font-size: calculateRem(16px); $ibexa-text-font-size-medium: calculateRem(14px); $ibexa-text-font-size-small: calculateRem(12px); +$ibexa-text-font-size-extra-small: calculateRem(8px); $ibexa-font-weight-normal: normal; $ibexa-font-weight-bold: 600; diff --git a/src/bundle/Resources/public/scss/_header-user-menu.scss b/src/bundle/Resources/public/scss/_header-user-menu.scss index bd844b9aed..4faadafe6a 100644 --- a/src/bundle/Resources/public/scss/_header-user-menu.scss +++ b/src/bundle/Resources/public/scss/_header-user-menu.scss @@ -22,7 +22,7 @@ padding: calculateRem(16px) calculateRem(24px); border-bottom: calculateRem(1px) solid $ibexa-color-light; color: $ibexa-color-dark-400; - font-size: $ibexa-text-font-size-small; + font-size: $ibexa-text-font-size-large; } .ibexa-focus-mode-form { @@ -87,15 +87,23 @@ } &__notice-dot { - width: calculateRem(6px); - height: calculateRem(6px); + width: calculateRem(12px); + height: calculateRem(12px); border-radius: 50%; background: $ibexa-color-danger; opacity: 1; cursor: pointer; position: absolute; - left: calculateRem(10px); + left: calculateRem(8px); top: 0; + color: $ibexa-color-white; + + &::after { + display: flex; + justify-content: center; + content: attr(data-count); + font-size: $ibexa-text-font-size-extra-small; + } &--no-notice { opacity: 0; diff --git a/src/bundle/Resources/public/scss/_list-filters.scss b/src/bundle/Resources/public/scss/_list-filters.scss new file mode 100644 index 0000000000..26a0489320 --- /dev/null +++ b/src/bundle/Resources/public/scss/_list-filters.scss @@ -0,0 +1,115 @@ +.ibexa-list-filters { + $self: &; + + overflow: hidden; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: calculateRem(11px) calculateRem(12px); + min-width: calculateRem(275px); + } + + &__title { + margin: 0; + padding: 0 0 0 calculateRem(8px); + font-weight: 600; + } + + &__sidebar { + &--collapsed { + width: calculateRem(68px); + margin-right: 0; + + .ibexa-list-filters__header > *:not(.ibexa-list-filters__expand-btn), + .ibexa-list-filters__items { + display: none; + } + } + } + + &__checkbox { + .form-check { + &:not(:first-child) { + margin-top: calculateRem(16px); + } + } + } + + .accordion-item { + background: transparent; + + #{$self} { + &__item-header-btn { + justify-content: space-between; + font-size: $ibexa-text-font-size-medium; + font-weight: 600; + transition: all $ibexa-admin-transition-duration $ibexa-admin-transition; + border-top-color: $ibexa-color-light; + border-bottom-color: transparent; + border-style: solid; + border-width: calculateRem(1px) 0; + background: transparent; + + .ibexa-icon--toggle { + transition: var(--bs-accordion-btn-icon-transition); + } + + &:not(.collapsed) { + border-bottom-color: $ibexa-color-light; + + .ibexa-icon--toggle { + transform: var(--bs-accordion-btn-icon-transform); + } + } + + &::after { + display: none; + } + } + } + + &:last-of-type { + #{$self} { + &__item-header-btn { + border-bottom-color: $ibexa-color-light; + + &.collapsed { + border-bottom-color: transparent; + } + } + } + } + } + + &__item { + .ibexa-label { + margin: 0; + padding: 0; + } + + &--date-time { + #{$self} { + &__item-content { + padding-bottom: calculateRem(48px); + } + } + + .form-group:first-child { + padding-bottom: calculateRem(16px); + } + .ibexa-date-time-picker { + width: 100%; + } + } + + &--hidden { + display: none; + } + } + + &__item-content { + padding: calculateRem(24px) calculateRem(16px); + } +} diff --git a/src/bundle/Resources/public/scss/_notifications-modal.scss b/src/bundle/Resources/public/scss/_notifications-modal.scss index 6a0fa1cffa..7351bec3b5 100644 --- a/src/bundle/Resources/public/scss/_notifications-modal.scss +++ b/src/bundle/Resources/public/scss/_notifications-modal.scss @@ -1,16 +1,26 @@ .ibexa-notifications-modal { cursor: auto; + &__footer { + display: flex; + justify-content: flex-end; + padding: calculateRem(8px); + background-color: $ibexa-color-white; + } + &__results { + max-height: calc(100vh - #{calculateRem(200px)}); + overflow-y: auto; + } - .modal-dialog { - max-width: 60vw; + &__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: calc(100vh - #{calculateRem(240px)}); } - .modal-header { - .modal-title { - &::after { - content: attr(data-notifications-total); - } - } + &__empty-text { + color: $ibexa-color-dark-300; } .table { @@ -18,22 +28,26 @@ white-space: normal; margin-bottom: 0; - th { - border: none; - color: $ibexa-color-dark-300; - border-top: calculateRem(1px) solid $ibexa-color-light; - border-bottom: calculateRem(1px) solid $ibexa-color-light; - } + .ibexa-table__row { + .ibexa-table__cell { + height: calculateRem(115px); + padding-right: 0; + border-radius: 0; + background-color: $ibexa-color-light-300; + } - tr { - background-color: $ibexa-color-white; - cursor: pointer; + &.ibexa-notifications-modal__item--read { + .ibexa-table__cell { + background-color: $ibexa-color-white; + } + } } } &__type { .type__icon { @include type-icon; + margin-left: calculateRem(16px); } .type__text { @@ -42,43 +56,82 @@ } &__type-content { - display: flex; - align-items: center; + width: 100%; + font-size: $ibexa-text-font-size-medium; + cursor: default; + + p { + margin-bottom: 0; + } } - &__item--read { - color: $ibexa-color-dark-300; + &__item { + position: relative; + border: calculateRem(1px) solid $ibexa-color-light; + border-top: none; - .type__icon { - @include type-icon-read; + &--wrapper { + display: flex; } - } - &__item--permanently-deleted { - .type__text, .description__text { - font-style: italic; + font-size: $ibexa-text-font-size-medium; + + &--permanently-deleted { + color: $ibexa-color-danger-500; + font-size: $ibexa-text-font-size-medium; + } + } + } + + &__item--date { + font-size: $ibexa-text-font-size-small; + color: $ibexa-color-light-700; + } + + &__notice-dot { + width: calculateRem(8px); + height: calculateRem(8px); + border-radius: 50%; + background: $ibexa-color-danger; + opacity: 1; + position: absolute; + left: calculateRem(16px); + top: calculateRem(22px); + color: $ibexa-color-white; + } + + &__view-all-btn { + &--count { + padding-left: calculateRem(2px); + } + } + + &__item--read { + .ibexa-notifications-modal__notice-dot, + .ibexa-notification-view-all__notice-dot { + background: $ibexa-color-dark-300; } } &__description { .description__title { margin-bottom: 0; + } - &__item { - display: inline-block; - vertical-align: top; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - font-weight: bold; - } + .description__title-item { + display: inline-block; + vertical-align: top; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + font-weight: bold; } .description__text { width: 100%; margin-bottom: 0; - max-width: 50ch; + max-width: calculateRem(350px); float: left; + .description__read-more { @@ -127,4 +180,110 @@ height: 2rem; } } + + &__actions { + .ibexa-icon { + margin-right: 0; + } + + .ibexa-btn { + margin-right: calculateRem(8px); + } + } +} + +.ibexa-notification-view-all { + display: flex; + align-items: center; + gap: calculateRem(4px); + flex-wrap: nowrap; + overflow: hidden; + + &__status-icon { + position: relative; + margin-right: calculateRem(12px); + } + + &__status { + position: relative; + } + + &__read { + margin-left: calculateRem(10px); + } + + &__date { + white-space: nowrap; + overflow: hidden; + } + + &__notice-dot { + width: calculateRem(8px); + height: calculateRem(8px); + border-radius: 50%; + background: $ibexa-color-danger; + opacity: 1; + position: absolute; + top: calculateRem(8px); + color: $ibexa-color-white; + + &--small { + width: calculateRem(6px); + height: calculateRem(6px); + } + + &[data-is-read='true'] { + background: $ibexa-color-dark-300; + } + + &[data-is-read='false'] { + background: $ibexa-color-danger; + } + } + + &__details { + display: flex; + gap: calculateRem(4px); + min-width: 0; + max-width: 100%; + overflow: hidden; + + .type__text, + .description__title { + flex-shrink: 0; + } + + .ibexa-notifications-modal__description { + display: flex; + max-width: 100%; + } + + .description__text { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 25vw; + } + } + + &__cell-wrapper { + font-size: $ibexa-text-font-size-medium; + padding-left: 0; + min-width: 0; + max-width: 100%; + + p { + margin-bottom: 0; + } + + .ibexa-table__cell.ibexa-table__cell { + padding: calculateRem(12px) 0; + } + } + + &__icon-hidden { + display: none; + } } diff --git a/src/bundle/Resources/public/scss/_notifications.scss b/src/bundle/Resources/public/scss/_notifications.scss index aaafd53efb..c30f72eaac 100644 --- a/src/bundle/Resources/public/scss/_notifications.scss +++ b/src/bundle/Resources/public/scss/_notifications.scss @@ -5,3 +5,70 @@ width: calculateRem(400px); z-index: 50000; } + +.ibexa-notification-list { + display: flex; + align-items: stretch; + margin-bottom: calculateRem(48px); + + &__container { + .container.container { + @media (min-width: 1200px) { + max-width: calculateRem(2000px); + } + } + } + + .ibexa-table__header-cell, + .ibexa-table__cell { + padding: calculateRem(16px) calculateRem(8px); + + &:first-child { + padding-left: calculateRem(16px); + } + + &:last-child { + padding-right: calculateRem(16px); + } + } + + .ibexa-container { + padding: 0 calculateRem(16px) calculateRem(16px); + margin-bottom: 0; + } + + .ibexa-table-header { + padding: calculateRem(8px) 0; + + &__headline { + font-size: $ibexa-text-font-size-extra-large; + } + } + &__btn { + display: flex; + justify-content: flex-end; + } + + &__data-grid-wrapper { + width: 100%; + border-radius: $ibexa-border-radius 0 0 $ibexa-border-radius; + border: calculateRem(1px) solid $ibexa-color-light; + border-right: none; + } + + &__filters-wrapper { + width: calculateRem(400px); + border-radius: 0 $ibexa-border-radius $ibexa-border-radius 0; + border: calculateRem(1px) solid $ibexa-color-light; + background-color: $ibexa-color-white; + transition: width $ibexa-admin-transition-duration $ibexa-admin-transition; + } + + &__table-btns { + font-size: $ibexa-text-font-size-medium; + } + + &__hidden-btn { + display: none; + } +} diff --git a/src/bundle/Resources/public/scss/_side-panel.scss b/src/bundle/Resources/public/scss/_side-panel.scss new file mode 100644 index 0000000000..51e0388cb7 --- /dev/null +++ b/src/bundle/Resources/public/scss/_side-panel.scss @@ -0,0 +1,47 @@ +.ibexa-side-panel { + background-color: $ibexa-color-white; + padding: calculateRem(8px) 0; + width: calculateRem(516px); + height: calc(100vh - calculateRem(73px)); + position: fixed; + top: calculateRem(73px); + right: 0; + z-index: 200; + + &__header { + padding: calculateRem(8px) calculateRem(32px) calculateRem(16px); + font-weight: bold; + border-bottom: calculateRem(1px) solid $ibexa-color-light; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + } + + &__content { + max-height: calc(100% - #{calculateRem(60px)}); + overflow: auto; + } + + &__footer { + display: flex; + align-items: center; + box-shadow: 0 0 calculateRem(16px) 0 rgba($ibexa-color-dark, 0.16); + z-index: 1000; + width: calculateRem(516px); + position: fixed; + bottom: 0; + + .ibexa-btn { + margin-right: calculateRem(16px); + } + + .ibexa-notifications-modal__footer { + width: calculateRem(516px); + } + } + + &--hidden { + display: none; + } +} diff --git a/src/bundle/Resources/public/scss/ibexa.scss b/src/bundle/Resources/public/scss/ibexa.scss index 8e7404b4b1..14ec41919c 100644 --- a/src/bundle/Resources/public/scss/ibexa.scss +++ b/src/bundle/Resources/public/scss/ibexa.scss @@ -81,6 +81,7 @@ @import 'dashboard'; @import 'picker'; @import 'notifications-modal'; +@import 'side-panel'; @import 'admin.section-view'; @import 'content-tree'; @import 'flatpickr'; @@ -107,6 +108,7 @@ @import 'grid-view'; @import 'grid-view-item'; @import 'list-search'; +@import 'list-filters'; @import 'search-links-form'; @import 'custom-url-form'; @import 'details'; diff --git a/src/bundle/Resources/translations/ibexa_admin_ui.en.xliff b/src/bundle/Resources/translations/ibexa_admin_ui.en.xliff index d4701e7130..b1f1754fe5 100644 --- a/src/bundle/Resources/translations/ibexa_admin_ui.en.xliff +++ b/src/bundle/Resources/translations/ibexa_admin_ui.en.xliff @@ -76,6 +76,11 @@ Select translation key: edit_translation.languages.select_language_title + + Cancel + Cancel + key: side_panel.btn.cancel_label + Removed '%languageCode%' translation from '%name%'. Removed '%languageCode%' translation from '%name%'. diff --git a/src/bundle/Resources/translations/ibexa_notifications.en.xliff b/src/bundle/Resources/translations/ibexa_notifications.en.xliff index 04f5c4acc1..9d2880fe58 100644 --- a/src/bundle/Resources/translations/ibexa_notifications.en.xliff +++ b/src/bundle/Resources/translations/ibexa_notifications.en.xliff @@ -6,46 +6,191 @@ The source node in most cases contains the sample message as written by the developer. If it looks like a dot-delimitted string such as "form.label.firstname", then the developer has not provided a default message. + + An unexpected error occurred while deleting notifications. + An unexpected error occurred while deleting notifications. + key: error.unexpected_delete_notifications + + + Apply + Apply + key: ibexa.notifications.search_form.apply + + + Clear + Clear + key: ibexa.notifications.search_form.clear + + + Date and Time + Date and Time + key: ibexa.notifications.search_form.label.date_and_time + + + Status + Status + key: ibexa.notifications.search_form.label.status + + + Type + Type + key: ibexa.notifications.search_form.label.type + + + Filters + Filters + key: ibexa.notifications.search_form.title + Notifications Notifications key: ibexa_notifications + + Mark all as read + Mark all as read + key: ibexa_notifications.btn.mark_all_as_read + View Notifications View Notifications key: menu.notification - - Date - Date - key: notification.date + + Title + Title + key: notification.Title + + + All types + All types + key: notification.all_types + + + Date and time + Date and time + key: notification.date_and_time + + + Date and time + Date and time + key: notification.datetime + + + Delete + Delete + key: notification.delete + + + Go to content + Go to content + key: notification.go_to_content + + + Mark as read + Mark as read + key: notification.mark_as_read + + + Mark as unread + Mark as unread + key: notification.mark_as_unread - - Description - Description - key: notification.description + + The Content item is no longer available + The Content item is no longer available + key: notification.no_longer_available Deleted Deleted key: notification.permanently_deleted + + Read + Read + key: notification.read + + + Status + Status + key: notification.status + + + Read + Read + key: notification.status.read + + + Unread + Unread + key: notification.status.unread + + + Title: + Title: + key: notification.title + Sent to Trash Sent to Trash key: notification.trashed - - Type - Type - key: notification.type + + moved to Trash + moved to Trash + key: notification.trashed.info + + + Unread + Unread + key: notification.unread + + + Are you sure you want to permanently delete the selected notification(s)? + Are you sure you want to permanently delete the selected notification(s)? + key: notifications.list.action.remove.confirmation.text + + + You don't have any notifications. + You don't have any notifications. + key: notifications.list.empty Cannot update notifications Cannot update notifications key: notifications.modal.message.error + + Cannot delete notification + Cannot delete notification + key: notifications.modal.message.error.delete + + + Cannot mark all notifications as read + Cannot mark all notifications as read + key: notifications.modal.message.error.mark_all_as_read + + + Cannot mark notification as read + Cannot mark notification as read + key: notifications.modal.message.error.mark_as_read + + + Cannot mark notification as unread + Cannot mark notification as unread + key: notifications.modal.message.error.mark_as_unread + + + Cannot mark selected notifications as read + Cannot mark selected notifications as read + key: notifications.modal.message.error.mark_selected_as_read + + + Cannot update this notification + Cannot update this notification + key: notifications.modal.message.error.update + diff --git a/src/bundle/Resources/translations/messages.en.xliff b/src/bundle/Resources/translations/messages.en.xliff index e03fd25686..26488cc099 100644 --- a/src/bundle/Resources/translations/messages.en.xliff +++ b/src/bundle/Resources/translations/messages.en.xliff @@ -101,11 +101,6 @@ Ibexa DXP]]> key: base.welcome - - You have no notifications. - You have no notifications. - key: bookmark.list.empty - Admin Admin @@ -463,6 +458,26 @@ We’ve sent to your email account a link to reset your password. key: ibexa.forgot_user_password.success.alert + + From + From + key: ibexa.notifications.search_form.label.from + + + To + To + key: ibexa.notifications.search_form.label.to + + + Notifications + Notifications + key: ibexa_notifications + + + Mark all as read + Mark all as read + key: ibexa_notifications.btn.mark_all_as_read + Delete Delete @@ -508,15 +523,10 @@ Password key: my_account_settings.password.title - - The Content item is no longer available - The Content item is no longer available - key: notification.no_longer_available - - - Title: - Title: - key: notification.title + + You don't have any notifications. + You don't have any notifications. + key: notifications.list.empty Delete @@ -593,6 +603,11 @@ Do you want to delete the Section(s)? key: section.modal.message + + View all notifications + View all notifications + key: side_panel.view_all + Swap Locations Swap Locations diff --git a/src/bundle/Resources/translations/validators.en.xliff b/src/bundle/Resources/translations/validators.en.xliff index 8ad4a705c0..435790e125 100644 --- a/src/bundle/Resources/translations/validators.en.xliff +++ b/src/bundle/Resources/translations/validators.en.xliff @@ -76,6 +76,11 @@ Selected Location has no children. key: ezplatform.trash.location_has_no_children + + The From date must be earlier than the To date. + The From date must be earlier than the To date. + key: ibexa.date_range.invalid_range + The Language code {{ value }} contains illegal characters. Language code should start with a letter, digit or underscore and only contain letters, digits, numbers, underscores, hyphens and colons. The Language code {{ value }} contains illegal characters. Language code should start with a letter, digit or underscore and only contain letters, digits, numbers, underscores, hyphens and colons. diff --git a/src/bundle/Resources/views/themes/admin/account/notifications/filters.html.twig b/src/bundle/Resources/views/themes/admin/account/notifications/filters.html.twig new file mode 100644 index 0000000000..5820efab60 --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/account/notifications/filters.html.twig @@ -0,0 +1,47 @@ +{% trans_default_domain 'ibexa_notifications' %} + +{% form_theme search_form with '@ibexadesign/account/notifications/filters/form_fields.html.twig' %} +{% set is_any_filter_set = search_form.vars.is_any_filter_set %} + +
+
+ +

{{ 'ibexa.notifications.search_form.title'|trans()|desc('Filters') }}

+
+ + +
+
+ +
+ {{ form_row(search_form.type) }} + {% if search_form.statuses is defined %} + {{ form_row(search_form.statuses, { attr: { class: 'ibexa-list-filters__checkbox'} }) }} + {% endif %} + {{ form_row(search_form.createdRange) }} + {{ form_rest(search_form) }} +
+
diff --git a/src/bundle/Resources/views/themes/admin/account/notifications/filters/filter_item.html.twig b/src/bundle/Resources/views/themes/admin/account/notifications/filters/filter_item.html.twig new file mode 100644 index 0000000000..1e156aecb0 --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/account/notifications/filters/filter_item.html.twig @@ -0,0 +1,19 @@ +
+ + + +
+ {% block content %} + {% endblock %} +
+
diff --git a/src/bundle/Resources/views/themes/admin/account/notifications/filters/form_fields.html.twig b/src/bundle/Resources/views/themes/admin/account/notifications/filters/form_fields.html.twig new file mode 100644 index 0000000000..5ebf1274ac --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/account/notifications/filters/form_fields.html.twig @@ -0,0 +1,65 @@ +{% extends '@ibexadesign/ui/form_fields.html.twig' %} + +{% trans_default_domain 'ibexa_notifications' %} + +{% block notification_type_choice_row %} + {% embed '@ibexadesign/account/notifications/filters/filter_item.html.twig' with { + target_id: form.vars.id|slug, + extra_class: 'ibexa-list-filters__item--type', + label: 'ibexa.notifications.search_form.label.type'|trans|desc('Type'), + } %} + {% block content %} + {{ form_widget(form) }} + {% endblock %} + {% endembed %} +{% endblock notification_type_choice_row %} + +{% block notification_status_choice_row %} + {% embed '@ibexadesign/account/notifications/filters/filter_item.html.twig' with { + target_id: form.vars.id|slug, + extra_class: 'ibexa-list-filters__item--statuses', + label: 'ibexa.notifications.search_form.label.status'|trans|desc('Status'), + } %} + {% block content %} + {{ form_widget(form) }} + {% endblock %} + {% endembed %} +{% endblock notification_status_choice_row %} + +{% block notification_created_range_row %} + {% embed '@ibexadesign/account/notifications/filters/filter_item.html.twig' with { + target_id: form.vars.id|slug, + extra_class: 'ibexa-list-filters__item--date-time', + label: 'ibexa.notifications.search_form.label.date_and_time'|trans|desc('Date and Time'), + } %} + {% block content %} +
+ + {{ form_widget(form.children.min, { + attr: { + 'data-seconds': 0, + } + }) }} +
+ {{ form_errors(form.children.min) }} +
+
+ +
+ + {{ form_widget(form.children.max, { + attr: { + 'data-seconds': 0, + } + }) }} +
+ {{ form_errors(form.children.max) }} +
+
+ {% endblock %} + {% endembed %} +{% endblock notification_created_range_row %} diff --git a/src/bundle/Resources/views/themes/admin/account/notifications/list.html.twig b/src/bundle/Resources/views/themes/admin/account/notifications/list.html.twig index 54b684a274..5ebd3206bc 100644 --- a/src/bundle/Resources/views/themes/admin/account/notifications/list.html.twig +++ b/src/bundle/Resources/views/themes/admin/account/notifications/list.html.twig @@ -1,12 +1,8 @@ {% trans_default_domain 'ibexa_notifications' %} {% embed '@ibexadesign/ui/component/table/table.html.twig' with { - head_cols: [ - { content: 'notification.type'|trans|desc('Type') }, - { content: 'notification.description'|trans|desc('Description') }, - { content: 'notification.date'|trans|desc('Date') }, - ], - class: 'ibexa-table--notifications', + head_cols: [], + class: 'ibexa-table--not-striped ibexa-list--notifications', attr: { 'data-notifications': path('ibexa.notifications.render.page'), 'data-notifications-count': path('ibexa.notifications.count'), @@ -16,27 +12,16 @@ } %} {% block tbody %} {% if pager.count is same as(0) %} - {% include '@ibexadesign/ui/component/table/empty_table_body_row.html.twig' with { - colspan: 3, - empty_table_info_text: 'bookmark.list.empty'|trans|desc('You have no notifications.'), - } %} +
+ +

{{ 'notifications.list.empty'|trans|desc('You don\'t have any notifications.') }}

+
{% else %} - {% block tbody_not_empty %} - {{ notifications|raw }} - {% endblock %} + {% for notification in notifications %} + {{ notification|raw }} + {% endfor %} {% endif %} {% endblock %} {% endembed %} - -{% if pager.haveToPaginate %} -
-
- {{ 'pagination.viewing'|trans({ - '%viewing%': pager.currentPageResults|length, - '%total%': pager.nbResults}, 'ibexa_pagination')|desc('Viewing %viewing% out of %total% items')|raw }} -
-
- {{ pagination|raw }} -
-
-{% endif %} diff --git a/src/bundle/Resources/views/themes/admin/account/notifications/list_all.html.twig b/src/bundle/Resources/views/themes/admin/account/notifications/list_all.html.twig new file mode 100644 index 0000000000..bc8c7e70da --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/account/notifications/list_all.html.twig @@ -0,0 +1,150 @@ +{% extends '@ibexadesign/ui/layout.html.twig' %} + +{% import '@ibexadesign/ui/component/macros.html.twig' as html %} +{% import _self as macros %} +{% from '@ibexadesign/ui/component/macros.html.twig' import results_headline %} + +{% trans_default_domain 'ibexa_notifications' %} +{% form_theme delete_form '@ibexadesign/ui/form_fields.html.twig' %} + +{% block main_container_class %}{{ parent() }} ibexa-notification-list__container {% endblock %} + +{% block title %}{{ 'ibexa_notifications'|trans|desc('Notifications') }}{% endblock %} +{% set no_items = pager.count is same as(0) %} +{% block header %} +
+ +
+ + {% include '@ibexadesign/ui/page_title.html.twig' with { + title: 'ibexa_notifications'|trans|desc('Notifications'), + } %} +{% endblock %} + +{% block content %} + {{ form_start(delete_form, { + action: path('ibexa.notifications.delete_multiple'), + 'attr': { + 'class': 'ibexa-toggle-btn-state ibexa-notification-list__hidden-btn', + 'data-toggle-button-id': '#confirm-selection_remove' + } + }) }} + {% for row in delete_form.notifications %} + {{ form_widget(row, { + 'attr': { + 'hidden': true + } + }) }} + {% endfor %} + + {{ form_end(delete_form) }} + {{ form_start(search_form, { + attr: { class: 'ibexa-list-search-form' } + }) }} +
+
+ {% embed '@ibexadesign/ui/component/table/table.html.twig' with { + headline: custom_results_headline ?? results_headline(pager.getNbResults()), + head_cols: [ + { has_checkbox: true }, + { content: 'notification.Title'|trans|desc('Title') }, + { content: 'notification.status'|trans|desc('Status') }, + { content: 'notification.datetime'|trans|desc('Date and time') }, + ], + class: 'ibexa-table--notifications', + actions: macros.table_header_tools(delete_form), + is_scrollable: false, + show_head_cols_if_empty: not no_items, + attr: { + 'data-notifications': path('ibexa.notifications.render.page'), + 'data-notifications-count': path('ibexa.notifications.count'), + 'data-notifications-count-interval': notifications_count_interval, + 'data-notifications-total': pager.nbResults, + }, + empty_table_info_text: 'notifications.list.empty'|trans|desc('You don\'t have any notifications.'), + } %} + {% block tbody %} + {% if no_items %} + {% include '@ibexadesign/ui/component/table/empty_table_body_row.html.twig' with { + colspan: 3, + empty_table_info_text: 'notifications.list.empty'|trans|desc('You don\'t have any notifications.'), + } %} + {% else %} + {% block tbody_not_empty %} + {% for notification in notifications %} + {{ notification|raw }} + {% endfor %} + {% endblock %} + {% endif %} + {% endblock %} + {% endembed %} + {% if pager.haveToPaginate %} +
+ {% include '@ibexadesign/ui/pagination.html.twig' with { + pager, + 'paginaton_params': { + 'routeName': 'ibexa.notifications.render.all', + } + } %} +
+ {% endif %} +
+
+ {% include '@ibexadesign/account/notifications/filters.html.twig' %} +
+
+ {{ form_end(search_form) }} +{% endblock %} + +{% block javascripts %} + {{ encore_entry_script_tags('ibexa-admin-notifications-list-js', null, 'ibexa') }} +{% endblock %} + +{% macro table_header_tools(form,) %} + {% set modal_data_target = 'modal-selection_remove' %} + +
+ + +
+ {% include '@ibexadesign/ui/modal/bulk_delete_confirmation.html.twig' with { + 'id': modal_data_target, + 'message': 'notifications.list.action.remove.confirmation.text'|trans|desc('Are you sure you want to permanently delete the selected notification(s)?'), + 'data_click': '#selection_remove', + } %} +{% endmacro %} diff --git a/src/bundle/Resources/views/themes/admin/account/notifications/list_item.html.twig b/src/bundle/Resources/views/themes/admin/account/notifications/list_item.html.twig index fe93cc00c0..6d8e0d2cbb 100644 --- a/src/bundle/Resources/views/themes/admin/account/notifications/list_item.html.twig +++ b/src/bundle/Resources/views/themes/admin/account/notifications/list_item.html.twig @@ -1,7 +1,7 @@ {% trans_default_domain 'ibexa_notifications' %} {% if wrapper_class_list is not defined %} - {% set wrapper_class_list = 'ibexa-notifications-modal__item' ~ (notification.isPending == 0 ? ' ibexa-notifications-modal__item--read') %} + {% set wrapper_class_list = 'ibexa-notifications-modal__item ibexa-notifications-modal__item--wrapper' ~ (notification.isPending == 0 ? ' ibexa-notifications-modal__item--read') %} {% endif %} {% set icon %} @@ -23,12 +23,7 @@ {% set message %} {% block message %} - {% embed '@ibexadesign/ui/component/table/table_body_cell.html.twig' with { class: 'ibexa-notifications-modal__description' } %} - {% block content %} -

{{ 'notification.title'|trans|desc('Title:') }} {{ title }}

-

{{ message }}

- {% endblock %} - {% endembed %} +

{{ message }}

{% endblock %} {% endset %} @@ -41,6 +36,19 @@ {% endblock %} {% endset %} +{% set popup_items = [] %} + {% set popup_items = popup_items|merge([{ + label: notification.isPending == 0 ? 'notification.mark_as_unread'|trans|desc('Mark as unread') : 'notification.mark_as_read'|trans|desc('Mark as read'), + action_attr: { + class: 'ibexa-notifications-modal--mark-as ibexa-notifications-modal--mark-as-' ~ (notification.isPending == 0 ? 'unread' : 'read'), + 'data-notification-id': notification.id }, + }]) %} + +{% set popup_items = popup_items|merge([{ + label: 'notification.delete'|trans|desc('Delete'), + action_attr: { class: 'ibexa-notifications-modal--delete', 'data-notification-id': notification.id }, +}]) %} + {% embed '@ibexadesign/ui/component/table/table_body_row.html.twig' with { class: wrapper_class_list ~ (wrapper_additional_classes is defined ? ' ' ~ wrapper_additional_classes), attr: { @@ -49,15 +57,48 @@ } } %} {% block body_row_cells %} - {% embed '@ibexadesign/ui/component/table/table_body_cell.html.twig' with { class: 'ibexa-notifications-modal__type' } %} + {% embed '@ibexadesign/ui/component/table/table_body_cell.html.twig' %} + {% block content %} +
+ +
{{ icon }}
+
+ {% endblock %} + {% endembed %} + {% embed '@ibexadesign/ui/component/table/table_body_cell.html.twig' with { class: 'ibexa-notifications-modal__type-content' } %} + {% block content %} + {{ notification_type }} +
{{ message }}
+
+ {{ notification.created|ibexa_short_datetime }} +
+ {% endblock %} + {% endembed %} + {% embed '@ibexadesign/ui/component/table/table_body_cell.html.twig' %} {% block content %} -
- {{ icon }} - {{ notification_type }} +
+ + {{ include('@ibexadesign/ui/component/multilevel_popup_menu/multilevel_popup_menu.html.twig', { + groups: [ + { + id: "notification-popup-menu-" ~ notification.id, + items: popup_items, + }, + ], + attr: { + 'data-trigger-element-selector': '#ibexa-notifications-modal-popup-trigger-' ~ notification.id, + 'data-initial-branch-placement': 'bottom-end', + }, + branch_attr: { + 'class': 'ibexa-notification-actions-popup-menu', + } + }) }}
{% endblock %} {% endembed %} - {{ message }} - {{ date }} {% endblock %} {% endembed %} diff --git a/src/bundle/Resources/views/themes/admin/account/notifications/list_item_all.html.twig b/src/bundle/Resources/views/themes/admin/account/notifications/list_item_all.html.twig new file mode 100644 index 0000000000..9dc71462e6 --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/account/notifications/list_item_all.html.twig @@ -0,0 +1,147 @@ +{% import "@ibexadesign/ui/component/macros.html.twig" as html %} + +{% trans_default_domain 'ibexa_notifications' %} + +{% set is_read = notification.isPending == 0 %} + +{% if wrapper_class_list is not defined %} + {% set wrapper_class_list = 'ibexa-notifications-modal__item' ~ (is_read ? ' ibexa-notifications-modal__item--read') %} +{% endif %} + +{% set icon %} + {% block icon %} + + + + + + {% endblock %} +{% endset %} + +{% set date %} + {% block date %} + {{ notification.created|ibexa_short_datetime }} + {% endblock %} +{% endset %} + +{% set notification_type %} + {% block notification_type %} + + {% endblock %} +{% endset %} + +{% set message %} + {% block message %} +

{{ message }}

+ {% endblock %} +{% endset %} + +{% set status %} +
+ + + {{is_read ? 'notification.read'|trans|desc('Read') : 'notification.unread'|trans|desc('Unread')}} + +
+{% endset %} + +{% set btn_show_content %} + {% block btn_show_content %} + {% set is_disabled = btn_disabled|default(false) %} + + + {% endblock %} +{% endset %} + +{% set icon_mail_open %} + + + +{% endset %} + +{% set icon_mail %} + + + +{% endset %} + +{% embed '@ibexadesign/ui/component/table/table_body_row.html.twig' with { + class: wrapper_class_list ~ (wrapper_additional_classes is defined ? ' ' ~ wrapper_additional_classes), + attr: { + 'data-notification-id': notification.id, + 'data-notification-read': path('ibexa.notifications.mark_as_read', { 'notificationId': notification.id }), + 'data-notification-unread': path('ibexa.notifications.mark_as_unread', { 'notificationId': notification.id }), +} +} %} + {% block body_row_cells %} + {% embed '@ibexadesign/ui/component/table/table_body_cell.html.twig' with { class: 'ibexa-table__cell--has-checkbox' } %} + {% block content %} +
+ +
+ {% endblock %} + {% endembed %} + {% embed '@ibexadesign/ui/component/table/table_body_cell.html.twig' with { class: 'ibexa-notification-view-all__cell-wrapper' } %} + {% block content %} +
+
+ +
{{ icon }}
+
+
+ {{ notification_type }} + {{ message }} +
+
+ {% endblock %} + {% endembed %} + {% embed '@ibexadesign/ui/component/table/table_body_cell.html.twig' %} + {% block content %} + {{ status }} + {% endblock %} + {% endembed %} + {% embed '@ibexadesign/ui/component/table/table_body_cell.html.twig' %} + {% block content %} + {{ date }} + {% endblock %} + {% endembed %} + {% embed '@ibexadesign/ui/component/table/table_body_cell.html.twig' %} + {% block content %} + {{ btn_show_content }} + {% endblock %} + {% endembed %} + {% embed '@ibexadesign/ui/component/table/table_body_cell.html.twig' %} + {% block content %} + + + {% endblock %} + {% endembed %} + {% endblock %} +{% endembed %} + diff --git a/src/bundle/Resources/views/themes/admin/account/notifications/list_item_deleted.html.twig b/src/bundle/Resources/views/themes/admin/account/notifications/list_item_deleted.html.twig index 97a9a41d33..2437abea20 100644 --- a/src/bundle/Resources/views/themes/admin/account/notifications/list_item_deleted.html.twig +++ b/src/bundle/Resources/views/themes/admin/account/notifications/list_item_deleted.html.twig @@ -1,20 +1,34 @@ -{% extends '@ibexadesign/account/notifications/list_item.html.twig' %} +{% extends template_to_extend %} {% trans_default_domain 'ibexa_notifications' %} -{% set wrapper_additional_classes = 'ibexa-notifications-modal__item--permanently-deleted' %} +{% set wrapper_additional_classes = 'ibexa-notifications-modal__item' %} + +{% block icon %} + + + + + +{% endblock %} {% block notification_type %} - + {{ 'notification.permanently_deleted'|trans|desc('Deleted')}} {% endblock %} +{% set btn_disabled = true %} +{% block btn_show_content %} + {{ parent() }} +{% endblock %} + {% block message %} - {% embed '@ibexadesign/ui/component/table/table_body_cell.html.twig' with { class: 'ibexa-notifications-modal__description' } %} - {% block content %} -

{{ 'notification.title'|trans|desc('Title:') }} {{ title }}

-

{{ 'notification.no_longer_available'|trans|desc('The Content item is no longer available')}}

- {% endblock %} - {% endembed %} + {% block content %} +

+ {{ 'notification.title'|trans|desc('Title:') }} + {{ title }} +

+

{{ 'notification.no_longer_available'|trans({}, 'ibexa_notifications')|desc('The Content item is no longer available')}}

+ {% endblock %} {% endblock %} diff --git a/src/bundle/Resources/views/themes/admin/account/notifications/list_item_trashed.html.twig b/src/bundle/Resources/views/themes/admin/account/notifications/list_item_trashed.html.twig index 4b0224f459..48127dd027 100644 --- a/src/bundle/Resources/views/themes/admin/account/notifications/list_item_trashed.html.twig +++ b/src/bundle/Resources/views/themes/admin/account/notifications/list_item_trashed.html.twig @@ -1,4 +1,4 @@ -{% extends '@ibexadesign/account/notifications/list_item.html.twig' %} +{% extends template_to_extend %} {% trans_default_domain 'ibexa_notifications' %} @@ -15,3 +15,13 @@ {{ 'notification.trashed'|trans|desc('Sent to Trash')}}
{% endblock %} + +{% block message %} + {% block content %} +

+ {{ 'notification.title'|trans|desc('Title:') }} + {{ title }} +

+

{{ 'notification.trashed.info'|trans({}, 'ibexa_notifications')|desc('moved to Trash')}}

+ {% endblock %} +{% endblock %} diff --git a/src/bundle/Resources/views/themes/admin/account/notifications/modal.html.twig b/src/bundle/Resources/views/themes/admin/account/notifications/modal.html.twig deleted file mode 100644 index cf339a35d4..0000000000 --- a/src/bundle/Resources/views/themes/admin/account/notifications/modal.html.twig +++ /dev/null @@ -1,24 +0,0 @@ -{% trans_default_domain 'ibexa_notifications' %} - -{% embed '@ibexadesign/ui/component/modal/modal.html.twig' with { - title: 'ibexa_notifications'|trans|desc('Notifications'), - class: 'ibexa-notifications-modal', - no_header_border: true, - id: 'view-notifications', - attr_close_btn: { - 'data-notifications-total': '', - }, -} %} - {% block body_content %} -
- - - -
-
- {{ render(controller('Ibexa\\Bundle\\AdminUi\\Controller\\NotificationController::renderNotificationsPageAction', { - 'page': 1, - })) }} -
- {% endblock %} -{% endembed %} diff --git a/src/bundle/Resources/views/themes/admin/account/notifications/side_panel.html.twig b/src/bundle/Resources/views/themes/admin/account/notifications/side_panel.html.twig new file mode 100644 index 0000000000..04bc2bbe13 --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/account/notifications/side_panel.html.twig @@ -0,0 +1,46 @@ +{% set max_visible_notifications_count = 10 %} + +{% embed '@ibexadesign/ui/component/side_panel/side_panel.html.twig' with { + title: 'ibexa_notifications'|trans|desc('Notifications'), + attr: { + 'data-actions': "create", + class: 'ibexa-notifications-modal ibexa-scroll-disabled', + id: 'view-notifications', + 'data-close-reload': 'false', + }, +}%} + {% block header %} +
+ {{ 'ibexa_notifications'|trans|desc('Notifications')}} + ({{max_visible_notifications_count}}) + + +
+ {% endblock %} + + {% block content %} +
+
+ + + +
+
+ {{ render(controller('Ibexa\\Bundle\\AdminUi\\Controller\\NotificationController::renderNotificationsPageAction', { + 'page': 1, + })) }} +
+
+ {% endblock %} + + {% block footer %} + + {% endblock %} +{% endembed %} diff --git a/src/bundle/Resources/views/themes/admin/ui/component/modal/modal.html.twig b/src/bundle/Resources/views/themes/admin/ui/component/modal/modal.html.twig index c38fea1445..7b7581faad 100644 --- a/src/bundle/Resources/views/themes/admin/ui/component/modal/modal.html.twig +++ b/src/bundle/Resources/views/themes/admin/ui/component/modal/modal.html.twig @@ -23,9 +23,7 @@ }) %} {% if id is defined %} - {% set attr = attr|default({})|merge({ - id, - }) %} + {% set attr = attr|default({})|merge({ id }) %} {% endif %} {% if has_static_backdrop|default(false) %} diff --git a/src/bundle/Resources/views/themes/admin/ui/component/side_panel/side_panel.html.twig b/src/bundle/Resources/views/themes/admin/ui/component/side_panel/side_panel.html.twig new file mode 100644 index 0000000000..495bc7ab56 --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/ui/component/side_panel/side_panel.html.twig @@ -0,0 +1,49 @@ +{% import '@ibexadesign/ui/component/macros.html.twig' as html %} + +{% trans_default_domain 'ibexa_admin_ui' %} + +{% set config_panel_main_class = 'ibexa-side-panel ibexa-side-panel--hidden' %} +{% set attr_footer = attr_footer|default({})|merge({ + class: ('ibexa-side-panel__footer' + ~ (footer_class is defined ? footer_class ~ ''))|trim, +}) %} + + +{% set attr = attr|default({})|merge({ + class: attr.class|default('')|trim ~ ' ' ~ config_panel_main_class, +}) %} + +{% if id is defined %} + {% set attr = attr|merge({ id }) %} +{% endif %} + +
+ {% block panel %} +
+ {% block header %} + +

{{ title }}

+ {% endblock %} + {% block content %}{% endblock %} + +
+ {% block footer %} + + {% endblock %} +
+
+ {% endblock %} +
+ diff --git a/src/bundle/Resources/views/themes/admin/ui/menu/user.html.twig b/src/bundle/Resources/views/themes/admin/ui/menu/user.html.twig index a57a98f057..99681e75c4 100644 --- a/src/bundle/Resources/views/themes/admin/ui/menu/user.html.twig +++ b/src/bundle/Resources/views/themes/admin/ui/menu/user.html.twig @@ -6,9 +6,8 @@
@@ -26,9 +25,9 @@ - - {{ include('@ibexadesign/account/notifications/modal.html.twig') }} - +
+ {{ include('@ibexadesign/account/notifications/side_panel.html.twig') }} +
{% block current_user %} diff --git a/src/lib/Behat/Component/UserNotificationPopup.php b/src/lib/Behat/Component/UserNotificationPopup.php index 91953b5269..7a585c6017 100644 --- a/src/lib/Behat/Component/UserNotificationPopup.php +++ b/src/lib/Behat/Component/UserNotificationPopup.php @@ -24,7 +24,11 @@ public function clickNotification(string $expectedType, string $expectedDescript continue; } - $description = $notification->find($this->getLocator('notificationDescription'))->getText(); + $notificationTitle = $this->getHTMLPage()->setTimeout(3)->find($this->getLocator('notificationDescriptionTitle'))->getText(); + $notificationText = $this->getHTMLPage()->setTimeout(3)->find($this->getLocator('notificationDescriptionText'))->getText(); + + $description = sprintf('%s %s', $notificationTitle, $notificationText); + if ($description !== $expectedDescription) { continue; } @@ -48,10 +52,11 @@ public function verifyIsLoaded(): void protected function specifyLocators(): array { return [ - new VisibleCSSLocator('notificationsPopupTitle', '#view-notifications .modal-title'), + new VisibleCSSLocator('notificationsPopupTitle', '.ibexa-side-panel__header'), new VisibleCSSLocator('notificationItem', '.ibexa-notifications-modal__item'), - new VisibleCSSLocator('notificationType', '.ibexa-notifications-modal__type'), - new VisibleCSSLocator('notificationDescription', '.ibexa-notifications-modal__description'), + new VisibleCSSLocator('notificationType', '.ibexa-notifications-modal__type-content > strong > span'), + new VisibleCSSLocator('notificationDescriptionTitle', '.ibexa-notifications-modal__type-content > p.description__title'), + new VisibleCSSLocator('notificationDescriptionText', '.ibexa-notifications-modal__type-content > p.description__text'), ]; } } diff --git a/src/lib/Form/Data/Notification/NotificationSelectionData.php b/src/lib/Form/Data/Notification/NotificationSelectionData.php new file mode 100644 index 0000000000..f46f44ad80 --- /dev/null +++ b/src/lib/Form/Data/Notification/NotificationSelectionData.php @@ -0,0 +1,39 @@ + */ + private array $notifications; + + /** + * @param array $notifications + */ + public function __construct(array $notifications = []) + { + $this->notifications = $notifications; + } + + /** + * @return array + */ + public function getNotifications(): array + { + return $this->notifications; + } + + /** + * @param array $notifications + */ + public function setNotifications(array $notifications): void + { + $this->notifications = $notifications; + } +} diff --git a/src/lib/Form/Factory/FormFactory.php b/src/lib/Form/Factory/FormFactory.php index 04e03891d4..ca74984688 100644 --- a/src/lib/Form/Factory/FormFactory.php +++ b/src/lib/Form/Factory/FormFactory.php @@ -8,6 +8,7 @@ namespace Ibexa\AdminUi\Form\Factory; +use Ibexa\AdminUi\Exception\InvalidArgumentException; use Ibexa\AdminUi\Form\Data\Bookmark\BookmarkRemoveData; use Ibexa\AdminUi\Form\Data\Content\ContentVisibilityUpdateData; use Ibexa\AdminUi\Form\Data\Content\CustomUrl\CustomUrlAddData; @@ -36,6 +37,7 @@ use Ibexa\AdminUi\Form\Data\Location\LocationTrashData; use Ibexa\AdminUi\Form\Data\Location\LocationUpdateData; use Ibexa\AdminUi\Form\Data\Location\LocationUpdateVisibilityData; +use Ibexa\AdminUi\Form\Data\Notification\NotificationSelectionData; use Ibexa\AdminUi\Form\Data\ObjectState\ObjectStateGroupCreateData; use Ibexa\AdminUi\Form\Data\ObjectState\ObjectStateGroupDeleteData; use Ibexa\AdminUi\Form\Data\ObjectState\ObjectStateGroupsDeleteData; @@ -92,6 +94,7 @@ use Ibexa\AdminUi\Form\Type\Location\LocationTrashType; use Ibexa\AdminUi\Form\Type\Location\LocationUpdateType; use Ibexa\AdminUi\Form\Type\Location\LocationUpdateVisibilityType; +use Ibexa\AdminUi\Form\Type\Notification\NotificationSelectionType; use Ibexa\AdminUi\Form\Type\ObjectState\ObjectStateGroupCreateType; use Ibexa\AdminUi\Form\Type\ObjectState\ObjectStateGroupDeleteType; use Ibexa\AdminUi\Form\Type\ObjectState\ObjectStateGroupsDeleteType; @@ -123,6 +126,7 @@ use Ibexa\AdminUi\Form\Type\User\UserEditType; use Ibexa\AdminUi\Form\Type\Version\VersionRemoveType; use Ibexa\Bundle\Search\Form\Data\SearchData; +use function is_string; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\Util\StringUtil; @@ -131,20 +135,12 @@ class FormFactory { - /** @var \Symfony\Component\Form\FormFactoryInterface */ - private $formFactory; + private FormFactoryInterface $formFactory; - /** @var \Symfony\Component\Routing\Generator\UrlGeneratorInterface */ - protected $urlGenerator; + protected UrlGeneratorInterface $urlGenerator; - /** @var \Symfony\Contracts\Translation\TranslatorInterface */ - private $translator; + private TranslatorInterface $translator; - /** - * @param \Symfony\Component\Form\FormFactoryInterface $formFactory - * @param \Symfony\Component\Routing\Generator\UrlGeneratorInterface $urlGenerator - * @param \Symfony\Contracts\Translation\TranslatorInterface $translator - */ public function __construct( FormFactoryInterface $formFactory, UrlGeneratorInterface $urlGenerator, @@ -156,8 +152,6 @@ public function __construct( } /** - * @param array $options - * * @throws \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException */ public function contentEdit( @@ -166,6 +160,14 @@ public function contentEdit( array $options = [] ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(ContentEditType::class); + + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + $data = $data ?? new ContentEditData(); if (empty($options['language_codes']) && null !== $data->getVersionInfo()) { @@ -190,6 +192,13 @@ public function createContent( $data = $data ?? new ContentCreateData(); $name = $name ?: StringUtil::fqcnToBlockPrefix(ContentCreateType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, ContentCreateType::class, $data); } @@ -202,6 +211,13 @@ public function deleteContentTypes( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(ContentTypesDeleteType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, ContentTypesDeleteType::class, $data); } @@ -211,6 +227,13 @@ public function createContentTypeGroup( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(ContentTypeGroupCreateType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed( $name, ContentTypeGroupCreateType::class, @@ -251,6 +274,13 @@ public function deleteContentTypeGroups( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(ContentTypeGroupsDeleteType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, ContentTypeGroupsDeleteType::class, $data); } @@ -287,18 +317,29 @@ public function removeVersion( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(VersionRemoveType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, VersionRemoveType::class, $data); } - /** - * @return \Symfony\Component\Form\FormInterface - */ public function addLocation( ?ContentLocationAddData $data = null, ?string $name = null ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(ContentLocationAddType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, ContentLocationAddType::class, $data); } @@ -308,6 +349,13 @@ public function removeLocation( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(ContentLocationRemoveType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, ContentLocationRemoveType::class, $data); } @@ -317,6 +365,13 @@ public function swapLocation( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(LocationSwapType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, LocationSwapType::class, $data); } @@ -328,6 +383,14 @@ public function updateContentMainLocation( ?string $name = null ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(ContentMainLocationUpdateType::class); + + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + $data = $data ?? new ContentMainLocationUpdateData(); return $this->formFactory->createNamed( @@ -342,6 +405,14 @@ public function trashLocation( ?string $name = null ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(LocationTrashType::class); + + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + $data = $data ?? new LocationTrashData(); return $this->formFactory->createNamed($name, LocationTrashType::class, $data); @@ -353,6 +424,13 @@ public function moveLocation( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(LocationMoveType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, LocationMoveType::class, $data); } @@ -362,6 +440,13 @@ public function copyLocation( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(LocationCopyType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, LocationCopyType::class, $data); } @@ -374,6 +459,13 @@ public function updateVisibilityLocation( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(LocationUpdateVisibilityData::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, LocationUpdateVisibilityType::class, $data); } @@ -385,6 +477,14 @@ public function updateVisibilityContent( ?string $name = null ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(ContentVisibilityUpdateType::class); + + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + $data = $data ?? new ContentVisibilityUpdateData(); return $this->formFactory->createNamed($name, ContentVisibilityUpdateType::class, $data); @@ -396,6 +496,13 @@ public function updateLocation( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(LocationUpdateType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, LocationUpdateType::class, $data); } @@ -405,6 +512,13 @@ public function assignContentSectionForm( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(SectionContentAssignType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, SectionContentAssignType::class, $data); } @@ -412,10 +526,17 @@ public function deleteSection( ?SectionDeleteData $data = null, ?string $name = null ): FormInterface { - if ($name === null && $data === null) { - throw new \InvalidArgumentException('Either $name or $data must be provided.'); + if ($name !== null) { + return $this->formFactory->createNamed($name, SectionDeleteType::class, $data); } - $name = $name ?: sprintf('delete-section-%d', $data->getSection()->id); + + if ($data === null || $data->getSection() === null) { + throw new \InvalidArgumentException( + 'SectionDeleteData with Section must be provided when $name is not set.' + ); + } + + $name = sprintf('delete-section-%d', $data->getSection()->id); return $this->formFactory->createNamed($name, SectionDeleteType::class, $data); } @@ -429,6 +550,13 @@ public function deleteSections( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(SectionsDeleteType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, SectionsDeleteType::class, $data); } @@ -438,6 +566,13 @@ public function createSection( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(SectionCreateType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed( $name, SectionCreateType::class, @@ -449,10 +584,17 @@ public function updateSection( ?SectionUpdateData $data = null, ?string $name = null ): FormInterface { - if ($name === null && $data === null) { - throw new \InvalidArgumentException('Either $name or $data must be provided.'); + if ($name !== null) { + return $this->formFactory->createNamed($name, SectionUpdateType::class, $data); + } + + if ($data === null || $data->getSection() === null) { + throw new \InvalidArgumentException( + 'SectionUpdateData with Section must be provided when $name is not set.' + ); } - $name = $name ?: sprintf('update-section-%d', $data->getSection()->id); + + $name = sprintf('update-section-%d', $data->getSection()->id); return $this->formFactory->createNamed($name, SectionUpdateType::class, $data); } @@ -463,6 +605,13 @@ public function createLanguage( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(LanguageCreateType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed( $name, LanguageCreateType::class, @@ -503,6 +652,13 @@ public function deleteLanguages( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(LanguagesDeleteType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, LanguagesDeleteType::class, $data); } @@ -512,6 +668,13 @@ public function createRole( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(RoleCreateType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, RoleCreateType::class, $data); } @@ -551,6 +714,13 @@ public function createRoleAssignment( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(RoleAssignmentCreateType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed( $name, RoleAssignmentCreateType::class, @@ -567,9 +737,10 @@ public function deleteRoleAssignment( ? $data->getRoleAssignment()->getRoleLimitation()->getIdentifier() : 'none'; - $name = $name ?: sprintf('delete-role-assignment-%s', md5( - implode('/', [$role, $limitation]) - )); + $name = $name ?: sprintf( + 'delete-role-assignment-%s', + hash('sha256', implode('/', [$role, $limitation])) + ); return $this->formFactory->createNamed($name, RoleAssignmentDeleteType::class, $data); } @@ -580,6 +751,13 @@ public function deleteRoleAssignments( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(RoleAssignmentsDeleteType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, RoleAssignmentsDeleteType::class, $data); } @@ -589,6 +767,13 @@ public function createPolicy( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(PolicyCreateType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, PolicyCreateType::class, $data); } @@ -598,6 +783,13 @@ public function createPolicyWithLimitation( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(PolicyCreateWithLimitationType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, PolicyCreateWithLimitationType::class, $data); } @@ -605,7 +797,10 @@ public function updatePolicy( PolicyUpdateData $data, ?string $name = null ): FormInterface { - $name = $name ?: sprintf('update-policy-%s', md5(implode('/', $data->getPolicy()))); + $name = $name ?: sprintf( + 'update-policy-%s', + hash('sha256', implode('/', $data->getPolicy() ?? [])) + ); return $this->formFactory->createNamed($name, PolicyUpdateType::class, $data); } @@ -614,7 +809,10 @@ public function deletePolicy( PolicyDeleteData $data, ?string $name = null ): FormInterface { - $name = $name ?: sprintf('delete-policy-%s', md5(implode('/', $data->getPolicy()))); + $name = $name ?: sprintf( + 'delete-policy-%s', + hash('sha256', implode('/', $data->getPolicy() ?? [])) + ); return $this->formFactory->createNamed($name, PolicyDeleteType::class, $data); } @@ -628,12 +826,16 @@ public function deletePolicies( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(PoliciesDeleteType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, PoliciesDeleteType::class, $data); } - /** - * @param array $options - */ public function createSearchForm( ?SearchData $data = null, ?string $name = null, @@ -641,12 +843,16 @@ public function createSearchForm( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(SearchData::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, SearchType::class, $data, $options); } - /** - * @param array $options - */ public function createUrlListForm( ?URLListData $data = null, ?string $name = null, @@ -654,12 +860,16 @@ public function createUrlListForm( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(SearchData::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, URLListType::class, $data, $options); } - /** - * @param array $options - */ public function createUrlEditForm( ?URLUpdateData $data = null, ?string $name = null, @@ -667,6 +877,13 @@ public function createUrlEditForm( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(SearchData::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, URLEditType::class, $data, $options); } @@ -676,6 +893,13 @@ public function deleteUser( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(UserDeleteType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, UserDeleteType::class, $data); } @@ -685,6 +909,13 @@ public function addCustomUrl( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(CustomUrlAddType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, CustomUrlAddType::class, $data ?? new CustomUrlAddData()); } @@ -694,6 +925,13 @@ public function removeCustomUrl( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(CustomUrlRemoveType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, CustomUrlRemoveType::class, $data ?? new CustomUrlRemoveData()); } @@ -703,6 +941,13 @@ public function createObjectStateGroup( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(ObjectStateGroupCreateType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed( $name, ObjectStateGroupCreateType::class, @@ -731,6 +976,13 @@ public function deleteObjectStateGroups( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(ObjectStateGroupsDeleteType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, ObjectStateGroupsDeleteType::class, $data); } @@ -752,6 +1004,13 @@ public function copyLocationSubtree( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(LocationCopySubtreeType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, LocationCopySubtreeType::class, $data); } @@ -761,6 +1020,13 @@ public function removeBookmark( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(BookmarkRemoveType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, BookmarkRemoveType::class, $data); } @@ -769,6 +1035,14 @@ public function editUser( ?string $name = null ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(UserEditType::class); + + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + $data = $data ?? new UserEditData(); $options = null !== $data->getVersionInfo() ? ['language_codes' => $data->getVersionInfo()->languageCodes] @@ -783,9 +1057,39 @@ public function removeContentDraft( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(ContentRemoveType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed($name, ContentRemoveType::class, $data); } + /** + * @return \Symfony\Component\Form\FormInterface<\Ibexa\AdminUi\Form\Data\Notification\NotificationSelectionData|null> + */ + public function deleteNotification( + NotificationSelectionData $data = null, + ?string $name = null + ): FormInterface { + $name = $name ?: StringUtil::fqcnToBlockPrefix(NotificationSelectionType::class); + + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + + return $this->formFactory->createNamed( + $name, + NotificationSelectionType::class, + $data + ); + } + /** * @throws \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException */ @@ -795,6 +1099,13 @@ public function createURLWildcard( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(URLWildcardType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed( $name, URLWildcardType::class, @@ -811,6 +1122,13 @@ public function createURLWildcardUpdate( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(URLWildcardUpdateType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed( $name, URLWildcardUpdateType::class, @@ -827,6 +1145,13 @@ public function deleteURLWildcard( ): FormInterface { $name = $name ?: StringUtil::fqcnToBlockPrefix(URLWildcardDeleteType::class); + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException( + 'name', + 'The form name must be a non-empty string.' + ); + } + return $this->formFactory->createNamed( $name, URLWildcardDeleteType::class, diff --git a/src/lib/Form/Type/Notification/NotificationCreatedRangeType.php b/src/lib/Form/Type/Notification/NotificationCreatedRangeType.php new file mode 100644 index 0000000000..f9637884e2 --- /dev/null +++ b/src/lib/Form/Type/Notification/NotificationCreatedRangeType.php @@ -0,0 +1,41 @@ + + */ +final class NotificationCreatedRangeType extends AbstractType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'required' => false, + 'label' => /** @Desc("Date and time") */ 'notification.date_and_time', + 'translation_domain' => 'ibexa_notifications', + 'constraints' => [new DateRangeConstraint()], + ]); + } + + public function getParent(): string + { + return DateRangeType::class; + } + + public function getBlockPrefix(): string + { + return 'notification_created_range'; + } +} diff --git a/src/lib/Form/Type/Notification/NotificationSelectionType.php b/src/lib/Form/Type/Notification/NotificationSelectionType.php new file mode 100644 index 0000000000..b0e2e8b33e --- /dev/null +++ b/src/lib/Form/Type/Notification/NotificationSelectionType.php @@ -0,0 +1,44 @@ + + */ +final class NotificationSelectionType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add( + 'notifications', + CollectionType::class, + [ + 'entry_type' => CheckboxType::class, + 'required' => false, + 'allow_add' => true, + 'entry_options' => ['label' => false], + 'label' => false, + ] + ); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => NotificationSelectionData::class, + ]); + } +} diff --git a/src/lib/Form/Type/Notification/NotificationStatusChoiceType.php b/src/lib/Form/Type/Notification/NotificationStatusChoiceType.php new file mode 100644 index 0000000000..e9e2dd4935 --- /dev/null +++ b/src/lib/Form/Type/Notification/NotificationStatusChoiceType.php @@ -0,0 +1,48 @@ + + */ +final class NotificationStatusChoiceType extends AbstractType +{ + public const NOTIFICATION_STATUS_READ = 0; + public const NOTIFICATION_STATUS_UNREAD = 1; + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'choices' => [ + /** @Desc("Read") */ + 'notification.status.read' => self::NOTIFICATION_STATUS_READ, + /** @Desc("Unread") */ + 'notification.status.unread' => self::NOTIFICATION_STATUS_UNREAD, + ], + 'translation_domain' => 'ibexa_notifications', + 'choice_translation_domain' => 'ibexa_notifications', + 'label' => /** @Desc("Status") */ 'notification.status', + ]); + } + + public function getParent(): string + { + return ChoiceType::class; + } + + public function getBlockPrefix(): string + { + return 'notification_status_choice'; + } +} diff --git a/src/lib/Form/Type/Notification/NotificationTypeChoiceType.php b/src/lib/Form/Type/Notification/NotificationTypeChoiceType.php new file mode 100644 index 0000000000..9e80dd5cf5 --- /dev/null +++ b/src/lib/Form/Type/Notification/NotificationTypeChoiceType.php @@ -0,0 +1,52 @@ + + */ +final class NotificationTypeChoiceType extends AbstractType +{ + private Registry $registry; + + public function __construct(Registry $registry) + { + $this->registry = $registry; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $typeLabels = $this->registry->getTypeLabels(); + + $choices = array_flip($typeLabels); + + $resolver->setDefaults([ + 'choices' => $choices, + 'required' => false, + 'placeholder' => /** @Desc("All types") */ 'notification.all_types', + 'translation_domain' => 'ibexa_notifications', + ]); + } + + public function getParent(): string + { + return ChoiceType::class; + } + + public function getBlockPrefix(): string + { + return 'notification_type_choice'; + } +} diff --git a/src/lib/Form/Type/Notification/SearchType.php b/src/lib/Form/Type/Notification/SearchType.php new file mode 100644 index 0000000000..f3843a69f4 --- /dev/null +++ b/src/lib/Form/Type/Notification/SearchType.php @@ -0,0 +1,64 @@ + + */ +final class SearchType extends AbstractType +{ + public function buildView(FormView $view, FormInterface $form, array $options): void + { + /** @var \Ibexa\Bundle\AdminUi\Form\Data\SearchQueryData|null $data */ + $data = $form->getData(); + $view->vars['is_any_filter_set'] = false; + + if ($data !== null) { + $statuses = $data->getStatuses(); + $type = $data->getType(); + $createdRange = $data->getCreatedRange(); + + $view->vars['is_any_filter_set'] = + (!empty($statuses)) || + (!empty($type)) || + ($createdRange !== null && ($createdRange->getMin() !== null || $createdRange->getMax() !== null)); + } + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('type', NotificationTypeChoiceType::class, [ + 'required' => false, + ]) + ->add('statuses', NotificationStatusChoiceType::class, [ + 'expanded' => true, + 'multiple' => true, + 'required' => false, + ]) + ->add('createdRange', NotificationCreatedRangeType::class, [ + 'required' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => SearchQueryData::class, + 'translation_domain' => 'ibexa_notifications', + ]); + } +} diff --git a/src/lib/Pagination/Pagerfanta/NotificationAdapter.php b/src/lib/Pagination/Pagerfanta/NotificationAdapter.php index ec6f6ab6e5..ed8d5b1c0f 100644 --- a/src/lib/Pagination/Pagerfanta/NotificationAdapter.php +++ b/src/lib/Pagination/Pagerfanta/NotificationAdapter.php @@ -9,6 +9,7 @@ use Ibexa\Contracts\Core\Repository\NotificationService; use Ibexa\Contracts\Core\Repository\Values\Notification\NotificationList; +use Ibexa\Contracts\Core\Repository\Values\Notification\Query\NotificationQuery; use Pagerfanta\Adapter\AdapterInterface; /** @@ -17,19 +18,18 @@ */ class NotificationAdapter implements AdapterInterface { - /** @var \Ibexa\Contracts\Core\Repository\NotificationService */ - private $notificationService; + private NotificationService $notificationService; - /** @var int */ - private $nbResults; + private NotificationQuery $query; + + private int $nbResults; - /** - * @param \Ibexa\Contracts\Core\Repository\NotificationService $notificationService - */ public function __construct( - NotificationService $notificationService + NotificationService $notificationService, + NotificationQuery $query ) { $this->notificationService = $notificationService; + $this->query = $query; } /** @@ -39,11 +39,15 @@ public function __construct( */ public function getNbResults(): int { - if ($this->nbResults !== null) { + if (isset($this->nbResults)) { return $this->nbResults; } - return $this->nbResults = $this->notificationService->getNotificationCount(); + $query = clone $this->query; + $query->setOffset(0); + $query->setLimit(0); + + return $this->nbResults = $this->notificationService->getNotificationCount($query); } /** @@ -56,11 +60,12 @@ public function getNbResults(): int */ public function getSlice($offset, $length): NotificationList { - $notifications = $this->notificationService->loadNotifications($offset, $length); + $query = clone $this->query; + $query->setOffset($offset); + $query->setLimit($length); + $notifications = $this->notificationService->findNotifications($query); - if (null === $this->nbResults) { - $this->nbResults = $notifications->totalCount; - } + $this->nbResults ??= $notifications->totalCount; return $notifications; } diff --git a/src/lib/Validator/Constraints/DateRangeConstraint.php b/src/lib/Validator/Constraints/DateRangeConstraint.php new file mode 100644 index 0000000000..fce70e857d --- /dev/null +++ b/src/lib/Validator/Constraints/DateRangeConstraint.php @@ -0,0 +1,33 @@ + + */ + public static function getTranslationMessages(): array + { + return [ + Message::create('ibexa.date_range.invalid_range', 'validators') + ->setDesc('The From date must be earlier than the To date.'), + ]; + } + + public function getTargets(): string + { + return self::CLASS_CONSTRAINT; + } +} diff --git a/src/lib/Validator/Constraints/DateRangeConstraintValidator.php b/src/lib/Validator/Constraints/DateRangeConstraintValidator.php new file mode 100644 index 0000000000..577ce1dd5b --- /dev/null +++ b/src/lib/Validator/Constraints/DateRangeConstraintValidator.php @@ -0,0 +1,46 @@ +getMin(); + $max = $value->getMax(); + + if ($min instanceof DateTimeInterface && $max instanceof DateTimeInterface && $min > $max) { + $this->context + ->buildViolation($constraint->message) + ->atPath('min') + ->addViolation(); + + $this->context + ->buildViolation($constraint->message) + ->atPath('max') + ->addViolation(); + } + } +} diff --git a/tests/lib/Validator/Constraint/DateRangeValidatorTest.php b/tests/lib/Validator/Constraint/DateRangeValidatorTest.php new file mode 100644 index 0000000000..68ad9a1f77 --- /dev/null +++ b/tests/lib/Validator/Constraint/DateRangeValidatorTest.php @@ -0,0 +1,64 @@ +validator->validate($data, new DateRangeConstraint()); + + $this->assertNoViolation(); + } + + public function testInvalidRange(): void + { + $data = new DateRangeData( + new DateTimeImmutable('2024-01-05 00:00:00'), + new DateTimeImmutable('2024-01-01 00:00:00'), + ); + + $constraint = new DateRangeConstraint([ + 'message' => 'ibexa.date_range.invalid_range', + ]); + + $this->validator->validate($data, $constraint); + + $this + ->buildViolation('ibexa.date_range.invalid_range') + ->atPath('property.path.min') + ->buildNextViolation('ibexa.date_range.invalid_range') + ->atPath('property.path.max') + ->assertRaised(); + } + + public function testNullValuesAreValid(): void + { + $data = new DateRangeData(null, null); + + $this->validator->validate($data, new DateRangeConstraint()); + + $this->assertNoViolation(); + } +}