diff --git a/dependencies.json b/dependencies.json new file mode 100644 index 0000000000..0ae273b138 --- /dev/null +++ b/dependencies.json @@ -0,0 +1,11 @@ +{ + "recipesEndpoint": "", + "packages": [ + { + "requirement": "dev-ibx_9060-notification-filtering as 4.6.x-dev", + "repositoryUrl": "https://github.com/ibexa/core", + "package": "ibexa/core", + "shouldBeAddedAsVCS": false + } + ] +} \ No newline at end of file diff --git a/src/bundle/Controller/AllNotificationsController.php b/src/bundle/Controller/AllNotificationsController.php new file mode 100644 index 0000000000..3fbdacb257 --- /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..8f046914cf 100644 --- a/src/bundle/Controller/NotificationController.php +++ b/src/bundle/Controller/NotificationController.php @@ -8,13 +8,21 @@ namespace Ibexa\Bundle\AdminUi\Controller; +use DateTimeInterface; +use Exception; +use Ibexa\AdminUi\Form\Data\Notification\NotificationSelectionData; +use Ibexa\AdminUi\Form\Factory\FormFactory; +use Ibexa\AdminUi\Form\SubmitHandler; 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\Bundle\AdminUi\Form\Type\SearchType; use Ibexa\Contracts\AdminUi\Controller\Controller; use Ibexa\Contracts\Core\Repository\NotificationService; +use Ibexa\Contracts\Core\Repository\Values\Notification\Query\Criterion; +use Ibexa\Contracts\Core\Repository\Values\Notification\Query\Criterion\NotificationQuery; use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; use Ibexa\Core\Notification\Renderer\Registry; +use InvalidArgumentException; use Pagerfanta\Pagerfanta; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -23,37 +31,34 @@ 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; public function __construct( NotificationService $notificationService, Registry $registry, TranslatorInterface $translator, - ConfigResolverInterface $configResolver + ConfigResolverInterface $configResolver, + FormFactory $formFactory, + SubmitHandler $submitHandler ) { $this->notificationService = $notificationService; $this->registry = $registry; $this->translator = $translator; $this->configResolver = $configResolver; + $this->formFactory = $formFactory; + $this->submitHandler = $submitHandler; } - /** - * @param \Symfony\Component\HttpFoundation\Request $request - * @param int $offset - * @param int $limit - * - * @return \Symfony\Component\HttpFoundation\JsonResponse - */ public function getNotificationsAction(Request $request, int $offset, int $limit): JsonResponse { $response = new JsonResponse(); @@ -65,7 +70,7 @@ public function getNotificationsAction(Request $request, int $offset, int $limit 'total' => $notificationList->totalCount, 'notifications' => $notificationList->items, ]); - } catch (\Exception $exception) { + } catch (Exception $exception) { $response->setData([ 'status' => 'failed', 'error' => $exception->getMessage(), @@ -75,42 +80,97 @@ public function getNotificationsAction(Request $request, int $offset, int $limit return $response; } - /** - * @param int $page - * - * @return \Symfony\Component\HttpFoundation\Response - */ - public function renderNotificationsPageAction(int $page): Response + public function renderNotificationsPageAction(Request $request, int $page): Response { + $allNotifications = $this->notificationService->loadNotifications(0, PHP_INT_MAX)->items; + + $notificationTypes = array_unique(array_column($allNotifications, 'type')); + sort($notificationTypes); + + $searchForm = $this->createForm(SearchType::class, null, [ + 'notification_types' => $notificationTypes, + ]); + $searchForm->handleRequest($request); + + $query = new NotificationQuery(); + if ($searchForm->isSubmitted() && $searchForm->isValid()) { + $query = $this->buildQuery($searchForm->getData()); + } + + $query->setOffset(($page - 1) * $this->configResolver->getParameter('pagination.notification_limit')); + $query->setLimit($this->configResolver->getParameter('pagination.notification_limit')); + $pagerfanta = new Pagerfanta( - new NotificationAdapter($this->notificationService) + new NotificationAdapter($this->notificationService, $query) ); - $pagerfanta->setMaxPerPage($this->configResolver->getParameter('pagination.notification_limit')); + $pagerfanta->setMaxPerPage($query->getLimit()); $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(), + 'form_remove' => $deleteForm->createView(), + ]); + } + + private function buildQuery(SearchQueryData $data): NotificationQuery + { + $criteria = []; + + if ($data->getType()) { + $criteria[] = new Criterion\Type($data->getType()); + } + + if (!empty($data->getStatuses())) { + $statuses = []; + if (in_array('read', $data->getStatuses(), true)) { + $statuses[] = 'read'; + } + if (in_array('unread', $data->getStatuses(), true)) { + $statuses[] = 'unread'; + } + + if (!empty($statuses)) { + $criteria[] = new Criterion\Status($statuses); + } + } + + $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); + } + + private function createNotificationSelectionData(Pagerfanta $pagerfanta): NotificationSelectionData + { + $notifications = []; + + foreach ($pagerfanta->getCurrentPageResults() as $notification) { + $notifications[$notification->id] = false; + } + + return new NotificationSelectionData($notifications); } /** @@ -125,7 +185,7 @@ public function countNotificationsAction(): JsonResponse 'pending' => $this->notificationService->getPendingNotificationCount(), 'total' => $this->notificationService->getNotificationCount(), ]); - } catch (\Exception $exception) { + } catch (Exception $exception) { $response->setData([ 'status' => 'failed', 'error' => $exception->getMessage(), @@ -139,13 +199,8 @@ public function countNotificationsAction(): JsonResponse * 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, mixed $notificationId): JsonResponse { $response = new JsonResponse(); @@ -165,7 +220,7 @@ public function markNotificationAsReadAction(Request $request, $notificationId): } $response->setData($data); - } catch (\Exception $exception) { + } catch (Exception $exception) { $response->setData([ 'status' => 'failed', 'error' => $exception->getMessage(), @@ -176,6 +231,126 @@ public function markNotificationAsReadAction(Request $request, $notificationId): return $response; } + + public function markNotificationsAsReadAction(Request $request): JsonResponse + { + $response = new JsonResponse(); + + try { + $ids = $request->toArray()['ids'] ?? []; + + if (!is_array($ids) || empty($ids)) { + throw new InvalidArgumentException('Missing or invalid "ids" parameter.'); + } + + foreach ($ids as $id) { + $notification = $this->notificationService->getNotification((int)$id); + $this->notificationService->markNotificationAsRead($notification); + } + + $response->setData([ + 'status' => 'success', + 'redirect' => $this->generateUrl('ibexa.notifications.render.all'), + ]); + } catch (Exception $exception) { + $response->setData([ + 'status' => 'failed', + 'error' => $exception->getMessage(), + ]); + $response->setStatusCode(400); + } + + return $response; + } + + public function markAllNotificationsAsReadAction(Request $request): JsonResponse + { + $response = new JsonResponse(); + + try { + $notifications = $this->notificationService->loadNotifications(0, PHP_INT_MAX)->items; + + foreach ($notifications as $notification) { + $this->notificationService->markNotificationAsRead($notification); + } + + return $response->setData(['status' => 'success']); + } catch (Exception $exception) { + return $response->setData([ + 'status' => 'failed', + 'error' => $exception->getMessage(), + ])->setStatusCode(404); + } + } + + public function markNotificationAsUnreadAction(Request $request, mixed $notificationId): JsonResponse + { + $response = new JsonResponse(); + + try { + $notification = $this->notificationService->getNotification((int)$notificationId); + + $this->notificationService->markNotificationAsUnread($notification); + + $data = ['status' => 'success']; + + $response->setData($data); + } catch (Exception $exception) { + $response->setData([ + 'status' => 'failed', + 'error' => $exception->getMessage(), + ]); + + $response->setStatusCode(404); + } + + return $response; + } + + public function deleteNotificationAction(Request $request, mixed $notificationId): JsonResponse + { + $response = new JsonResponse(); + + try { + $notification = $this->notificationService->getNotification((int)$notificationId); + + $this->notificationService->deleteNotification($notification); + + $response->setData(['status' => 'success']); + } catch (Exception $exception) { + $response->setData([ + 'status' => 'failed', + 'error' => $exception->getMessage(), + ]); + + $response->setStatusCode(404); + } + + return $response; + } + + public function deleteNotificationsAction(Request $request): Response + { + $form = $this->formFactory->deleteNotification(); + $form->handleRequest($request); + + if ($form->isSubmitted()) { + $result = $this->submitHandler->handle($form, function (NotificationSelectionData $data) { + foreach (array_keys($data->getNotifications()) as $id) { + $notification = $this->notificationService->getNotification((int)$id); + $this->notificationService->deleteNotification($notification); + } + + return $this->redirectToRoute('ibexa.notifications.render.all'); + }); + + if ($result instanceof Response) { + return $result; + } + } + + return $this->redirectToRoute('ibexa.notifications.render.all'); + } } class_alias(NotificationController::class, 'EzSystems\EzPlatformAdminUiBundle\Controller\NotificationController'); 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/Form/Type/SearchType.php b/src/bundle/Form/Type/SearchType.php new file mode 100644 index 0000000000..b6bae53744 --- /dev/null +++ b/src/bundle/Form/Type/SearchType.php @@ -0,0 +1,57 @@ + + */ +final class SearchType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('type', ChoiceType::class, [ + 'required' => false, + 'choices' => array_combine($options['notification_types'], $options['notification_types']), + 'placeholder' => 'All types', + 'label' => 'Type', + ]) + ->add('statuses', ChoiceType::class, [ + 'choices' => [ + 'Read' => 'read', + 'Unread' => 'unread', + ], + 'expanded' => true, + 'multiple' => true, + 'required' => false, + 'label' => 'Status', + ]) + ->add('createdRange', DateRangeType::class, [ + 'required' => false, + 'label' => 'Date and time', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'method' => 'POST', + 'csrf_protection' => false, + 'data_class' => SearchQueryData::class, + 'notification_types' => [], + ]); + } +} 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 18263e88b9..98df700bae 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 + defaults: + _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: [GET] + 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 e6384a6886..c9add9d9d8 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/encore/ibexa.js.config.js b/src/bundle/Resources/encore/ibexa.js.config.js index b0e571de94..447c7a322e 100644 --- a/src/bundle/Resources/encore/ibexa.js.config.js +++ b/src/bundle/Resources/encore/ibexa.js.config.js @@ -38,6 +38,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'), @@ -252,5 +253,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..f83f4c53f7 --- /dev/null +++ b/src/bundle/Resources/public/js/scripts/admin.notifications.filters.js @@ -0,0 +1,119 @@ +(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', filterChange, 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 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..d5d82b69e4 --- /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__btn--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 initStatusIcons = () => { + 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); + }; + + initStatusIcons(); + + 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.every( + (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-notification_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..5c608db82e 100644 --- a/src/bundle/Resources/public/js/scripts/admin.notifications.modal.js +++ b/src/bundle/Resources/public/js/scripts/admin.notifications.modal.js @@ -1,20 +1,20 @@ -(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_MODAL_RESULTS = '.ibexa-notifications-modal__results .ibexa-scrollable-wrapper'; + const SELECTOR_MODAL_TITLE = '.ibexa-side-panel__header'; const SELECTOR_DESC_TEXT = '.description__text'; - const SELECTOR_TABLE = '.ibexa-table--notifications'; + const SELECTOR_LIST = '.ibexa-list--notifications'; const CLASS_ELLIPSIS = 'description__text--ellipsis'; const CLASS_PAGINATION_LINK = 'page-link'; 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) => { + const handleNotificationClickRequest = (notification, response) => { if (response.status === 'success') { notification.classList.add('ibexa-notifications-modal__item--read'); } @@ -30,7 +30,7 @@ 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')) { @@ -47,8 +47,9 @@ 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 +68,6 @@ }) .catch(onGetNotificationsStatusFailure); }; - - /** - * Handle a failure while getting notifications status - * - * @method onGetNotificationsStatusFailure - */ const onGetNotificationsStatusFailure = (error) => { if (lastFailedCountFetchNotificationNode && doc.contains(lastFailedCountFetchNotificationNode)) { return; @@ -93,19 +88,26 @@ 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})`; + + 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 +117,146 @@ 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(); + } 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); + + menuInstance.closeMenu(); + notification.classList.add('ibexa-notifications-modal__item--read'); + 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); + + menuInstance.closeMenu(); + notification.classList.remove('ibexa-notifications-modal__item--read'); + getNotificationsStatus(); + } else { + showErrorNotification(message); + } + }) + .catch(() => { + showErrorNotification(message); + }); + }; + 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, { 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(); + } else { + showErrorNotification(message); + } + }) + .catch(() => { + showErrorNotification(message); + }); + }; + const attachActionsListeners = () => { + const attachListener = (node, callback) => node.addEventListener('click', callback, false); + const markAsReadButtons = doc.querySelectorAll('.ibexa-notifications-modal--mark-as-read'); + const markAsUnreadButtons = doc.querySelectorAll('.ibexa-notifications-modal--mark-as-unread'); + const deleteButtons = doc.querySelectorAll('.ibexa-notifications-modal--delete'); + + markAsReadButtons.forEach((markAsReadButton) => { + attachListener(markAsReadButton, markAsRead); + }); + + markAsUnreadButtons.forEach((markAsUnreadButton) => { + attachListener(markAsUnreadButton, markAsUnread); + }); + + 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) { @@ -144,9 +278,15 @@ }; const handleModalResultsClick = (event) => { const isPaginationBtn = event.target.classList.contains(CLASS_PAGINATION_LINK); + const isActionBtn = event.target.closest('.ibexa-notifications-modal__actions'); + + if (isActionBtn) { + return; + } if (isPaginationBtn) { handleNotificationsPageChange(event); + return; } @@ -160,15 +300,17 @@ fetchNotificationPage(notificationsPageLink); }; - 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 +319,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..53d17fa014 --- /dev/null +++ b/src/bundle/Resources/public/js/scripts/sidebar/side.panel.js @@ -0,0 +1,56 @@ +(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(); + } + }; + + 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 c4f0ba370c..8aad9d108d 100644 --- a/src/bundle/Resources/public/scss/_header-user-menu.scss +++ b/src/bundle/Resources/public/scss/_header-user-menu.scss @@ -21,7 +21,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 { @@ -86,15 +86,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..006614eb89 --- /dev/null +++ b/src/bundle/Resources/public/scss/_list-filters.scss @@ -0,0 +1,107 @@ +.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; + } + } + } + + .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..e0f1aec1cd 100644 --- a/src/bundle/Resources/public/scss/_notifications-modal.scss +++ b/src/bundle/Resources/public/scss/_notifications-modal.scss @@ -1,16 +1,14 @@ .ibexa-notifications-modal { cursor: auto; - - .modal-dialog { - max-width: 60vw; + &__footer { + display: flex; + justify-content: flex-end; + padding: calculateRem(8px); + background-color: $ibexa-color-white; } - - .modal-header { - .modal-title { - &::after { - content: attr(data-notifications-total); - } - } + &__results { + max-height: calc(100vh - #{calculateRem(200px)}); + overflow-y: auto; } .table { @@ -18,22 +16,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,37 +44,75 @@ } &__type-content { - display: flex; - align-items: center; + width: 100%; + font-size: $ibexa-text-font-size-medium; + + 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 { @@ -127,4 +167,108 @@ 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 { + white-space: nowrap; + } + + .ibexa-notifications-modal__description { + display: flex; + max-width: 100%; + } + + .description__text { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + &__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..c6ba73af27 100644 --- a/src/bundle/Resources/public/scss/_notifications.scss +++ b/src/bundle/Resources/public/scss/_notifications.scss @@ -5,3 +5,72 @@ 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 { + &--mark-all-as-read { + 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(360px); + 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 bb74981f5f..34c3e97ab6 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 8272a323fb..e7ea82e501 100644 --- a/src/bundle/Resources/translations/ibexa_admin_ui.en.xliff +++ b/src/bundle/Resources/translations/ibexa_admin_ui.en.xliff @@ -56,6 +56,11 @@ Select language key: edit_translation.list.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..9f6e5ec681 100644 --- a/src/bundle/Resources/translations/ibexa_notifications.en.xliff +++ b/src/bundle/Resources/translations/ibexa_notifications.en.xliff @@ -6,46 +6,156 @@ 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. + + 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 + + + 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 - - Description - Description - key: notification.description + + Mark as unread + Mark as unread + key: notification.mark_as_unread + + + 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 + + + Title: + Title: + key: notification.title + Sent to Trash Sent to Trash key: notification.trashed - - Type - Type - key: notification.type + + 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 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..15a46e2a99 100644 --- a/src/bundle/Resources/translations/messages.en.xliff +++ b/src/bundle/Resources/translations/messages.en.xliff @@ -463,6 +463,16 @@ We’ve sent to your email account a link to reset your password. key: ibexa.forgot_user_password.success.alert + + Notifications + Notifications + key: ibexa_notifications + + + Mark all as read + Mark all as read + key: ibexa_notifications.btn.mark_all_as_read + Delete Delete @@ -508,16 +518,6 @@ 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 - Delete Delete @@ -593,6 +593,11 @@ Do you want to delete the Section(s)? key: section.modal.message + + View all + View all + key: side_panel.view_all + Swap Locations Swap Locations 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..70f27cfb13 --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/account/notifications/filters.html.twig @@ -0,0 +1,53 @@ +{% trans_default_domain 'ibexa_notifications' %} + +{% form_theme search_form with '@ibexadesign/account/notifications/filters/form_fields.html.twig' %} + +{% set is_some_filter_set = + (search_form.statuses is defined and search_form.statuses.vars.value|length) or + search_form.type.vars.value|length or + (search_form.createdRange.vars.value is not null and (search_form.createdRange.vars.value.min is not null or + search_form.createdRange.vars.value.max is not null)) +%} + +
+
+ +

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

+
+ + +
+
+ +
+ {{ form_row(search_form.type) }} + {% if search_form.statuses is defined %} + {{ form_row(search_form.statuses) }} + {% 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..0115e3fd98 --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/account/notifications/filters/form_fields.html.twig @@ -0,0 +1,55 @@ +{% extends '@ibexadesign/ui/form_fields.html.twig' %} + +{% trans_default_domain 'ibexa_notifications' %} + +{% block _search_type_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 _search_type_row %} + +{% block _search_statuses_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 _search_statuses_row %} + +{% block _search_createdRange_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_label(form.children.min) }} + {{ form_widget(form.children.min, { + attr: { + 'data-seconds': 0, + } + }) }} +
+ +
+ {{ form_label(form.children.max) }} + {{ form_widget(form.children.max, { + attr: { + 'data-seconds': 0, + } + }) }} +
+ {% endblock %} + {% endembed %} +{% endblock _search_createdRange_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..6d248f01c9 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,11 @@ } %} {% 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.'), - } %} + {{ 'bookmark.list.empty'|trans|desc('You have no 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..ef5044294a --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/account/notifications/list_all.html.twig @@ -0,0 +1,146 @@ +{% 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 form_remove '@ibexadesign/ui/form_fields.html.twig' %} + +{% block main_container_class %}{{ parent() }} ibexa-notification-list__container {% endblock %} + +{% block title %}{{ 'ibexa_notifications'|trans|desc('Notifications') }}{% endblock %} + +{% block header %} +
+ +
+ + {% include '@ibexadesign/ui/page_title.html.twig' with { + title: 'ibexa_notifications'|trans|desc('Notifications'), + } %} +{% endblock %} + +{% block content %} + {{ form_start(form_remove, { + action: path('ibexa.notifications.delete_multiple'), + 'attr': { + 'class': 'ibexa-toggle-btn-state ibexa-notification-list__hidden-btn', + 'data-toggle-button-id': '#confirm-' ~ form_remove.remove.vars.id + } + }) }} + {% for row in form_remove.notifications %} + {{ form_widget(row, { + 'attr': { + 'hidden': true + } + }) }} + {% endfor %} + {{ form_widget(form_remove.remove, { + 'attr': { + 'hidden': true + } + }) }} + {{ form_end(form_remove) }} + {{ 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(form_remove), + is_scrollable: false, + show_head_cols_if_empty: true, + 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, + }, + } %} + {% 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.'), + } %} + {% 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-' ~ form.remove.vars.id %} + +
+ + +
+ {% 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': '#' ~ form.remove.vars.id, + } %} +{% 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..836cd8e7a8 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,25 @@ {% endblock %} {% endset %} +{% set popup_items = [] %} + +{% if notification.isPending == 0 %} + {% set popup_items = popup_items|merge([{ + label: 'notification.mark_as_unread'|trans|desc('Mark as unread'), + action_attr: { class: 'ibexa-notifications-modal--mark-as-unread', 'data-notification-id': notification.id }, + }]) %} +{% else %} + {% set popup_items = popup_items|merge([{ + label: 'notification.mark_as_read'|trans|desc('Mark as read'), + action_attr: { class: 'ibexa-notifications-modal--mark-as-read', 'data-notification-id': notification.id }, + }]) %} +{% endif %} + +{% 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 +63,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..1c4d260563 --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/account/notifications/list_item_all.html.twig @@ -0,0 +1,142 @@ +{% 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 icon_show %} + + + +{% 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 %} + + {% 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..64930fc793 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,29 @@ -{% 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 %} {% 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')}}

+

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

+

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

{% endblock %} - {% endembed %} {% 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..6abd1ae9cb --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/account/notifications/side_panel.html.twig @@ -0,0 +1,45 @@ +{% 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', + }, +}%} + {% 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 5b669cb280..2a81adcf57 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..0166723b4b --- /dev/null +++ b/src/lib/Form/Data/Notification/NotificationSelectionData.php @@ -0,0 +1,39 @@ +notifications = $notifications; + } + + /** + * @return \Ibexa\Contracts\Core\Repository\Values\Notification\Notification[] + */ + public function getNotifications(): array + { + return $this->notifications; + } + + /** + * @param \Ibexa\Contracts\Core\Repository\Values\Notification\Notification[] $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 9ba2df1596..a763845820 100644 --- a/src/lib/Form/Factory/FormFactory.php +++ b/src/lib/Form/Factory/FormFactory.php @@ -36,6 +36,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 +93,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; @@ -1064,6 +1066,27 @@ public function removeContentDraft( return $this->formFactory->createNamed($name, ContentRemoveType::class, $data); } + /** + * @param \Ibexa\AdminUi\Form\Data\Notification\NotificationSelectionData|null $data + * @param string|null $name + * + * @return \Symfony\Component\Form\FormInterface<\Ibexa\AdminUi\Form\Data\Notification\NotificationSelectionData|null> + */ + public function deleteNotification( + NotificationSelectionData $data = null, + ?string $name = null, + string $submitName = 'remove' + ): FormInterface { + $name = $name ?: StringUtil::fqcnToBlockPrefix(NotificationSelectionType::class); + + return $this->formFactory->createNamed( + $name, + NotificationSelectionType::class, + $data, + ['submit_name' => $submitName] + ); + } + /** * @param \Ibexa\AdminUi\Form\Data\URLWildcard\URLWildcardData|null $data * @param string|null $name @@ -1118,7 +1141,7 @@ public function deleteURLWildcard( ?URLWildcardDeleteData $data = null, ?string $name = null ): FormInterface { - $name = $name ?: StringUtil::fqcnToBlockPrefix(URLWildcardDeleteType::class); + $name = (string)($name ?: StringUtil::fqcnToBlockPrefix(URLWildcardDeleteType::class)); return $this->formFactory->createNamed( $name, diff --git a/src/lib/Form/Type/Notification/NotificationSelectionType.php b/src/lib/Form/Type/Notification/NotificationSelectionType.php new file mode 100644 index 0000000000..4566981b75 --- /dev/null +++ b/src/lib/Form/Type/Notification/NotificationSelectionType.php @@ -0,0 +1,51 @@ + + */ +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, + ] + ); + + $builder->add( + $options['submit_name'], + SubmitType::class + ); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => NotificationSelectionData::class, + 'submit_name' => 'submit', + ]); + } +} diff --git a/src/lib/Pagination/Pagerfanta/NotificationAdapter.php b/src/lib/Pagination/Pagerfanta/NotificationAdapter.php index ec6f6ab6e5..7bf9514428 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\Criterion\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,7 @@ public function __construct( */ public function getNbResults(): int { - if ($this->nbResults !== null) { - return $this->nbResults; - } - - return $this->nbResults = $this->notificationService->getNotificationCount(); + return $this->nbResults ?? ($this->nbResults = $this->notificationService->getNotificationCount($this->query)); } /** @@ -56,11 +52,9 @@ public function getNbResults(): int */ public function getSlice($offset, $length): NotificationList { - $notifications = $this->notificationService->loadNotifications($offset, $length); + $notifications = $this->notificationService->findNotifications($this->query); - if (null === $this->nbResults) { - $this->nbResults = $notifications->totalCount; - } + $this->nbResults ??= $notifications->totalCount; return $notifications; }