diff --git a/phpstan-baseline-7.4.neon b/phpstan-baseline-7.4.neon index 8df75b2147..3aabfa93f4 100644 --- a/phpstan-baseline-7.4.neon +++ b/phpstan-baseline-7.4.neon @@ -210,11 +210,6 @@ parameters: count: 1 path: src/lib/Pagination/Mapper/AbstractPagerContentToDataMapper.php - - - message: "#^Parameter \\#1 \\$input of function array_filter expects array, iterable\\ given\\.$#" - count: 1 - path: src/lib/Permission/PermissionChecker.php - - message: "#^Parameter \\#1 \\$var of function count expects array\\|Countable, iterable\\ given\\.$#" count: 1 diff --git a/phpstan-baseline-8.0.neon b/phpstan-baseline-8.0.neon index 5d7532674a..31d6260782 100644 --- a/phpstan-baseline-8.0.neon +++ b/phpstan-baseline-8.0.neon @@ -150,11 +150,6 @@ parameters: count: 1 path: src/lib/Pagination/Mapper/AbstractPagerContentToDataMapper.php - - - message: "#^Parameter \\#1 \\$array of function array_filter expects array, iterable\\ given\\.$#" - count: 1 - path: src/lib/Permission/PermissionChecker.php - - message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, iterable\\ given\\.$#" count: 1 diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e7bb9261d4..7664e76141 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -8445,6 +8445,11 @@ parameters: count: 1 path: src/lib/Pagination/Pagerfanta/URLWildcardAdapter.php + - + message: "#^Access to protected property Ibexa\\\\Contracts\\\\Core\\\\Repository\\\\Values\\\\User\\\\LookupLimitationResult\\:\\:\\$hasAccess\\.$#" + count: 1 + path: src/lib/Permission/LimitationResolver.php + - message: "#^Access to protected property Ibexa\\\\Contracts\\\\Core\\\\Repository\\\\Values\\\\User\\\\LookupLimitationResult\\:\\:\\$lookupPolicyLimitations\\.$#" count: 2 @@ -8500,11 +8505,6 @@ parameters: count: 1 path: src/lib/Permission/PermissionChecker.php - - - message: "#^Parameter \\#1 \\$contentTypeIds of method Ibexa\\\\Contracts\\\\Core\\\\Limitation\\\\Target\\\\Builder\\\\VersionBuilder\\:\\:createFromAnyContentTypeOf\\(\\) expects array\\, array\\ given\\.$#" - count: 2 - path: src/lib/Permission/PermissionChecker.php - - message: "#^Method Ibexa\\\\AdminUi\\\\QueryType\\\\LocationPathQueryType\\:\\:doGetQuery\\(\\) has parameter \\$parameters with no value type specified in iterable type array\\.$#" count: 1 diff --git a/src/bundle/Controller/Permission/LanguageLimitationController.php b/src/bundle/Controller/Permission/LanguageLimitationController.php new file mode 100644 index 0000000000..ace7f50b5e --- /dev/null +++ b/src/bundle/Controller/Permission/LanguageLimitationController.php @@ -0,0 +1,120 @@ +contentService = $contentService; + $this->limitationResolver = $limitationResolver; + $this->locationService = $locationService; + } + + public function loadLanguageLimitationsForContentCreateAction(Location $location): Response + { + $contentInfo = $location->getContentInfo(); + $contentType = $contentInfo->getContentType(); + $contentCreateStruct = $this->contentService->newContentCreateStruct( + $contentType, + $contentInfo->getMainLanguageCode() + ); + $contentCreateStruct->sectionId = $contentInfo->getSection(); + $locationCreateStruct = $this->locationService->newLocationCreateStruct($location->id); + + return new JsonResponse( + $this->limitationResolver->getLanguageLimitations( + 'create', + $contentCreateStruct, + [], + [ + $locationCreateStruct, + ] + ) + ); + } + + public function loadLanguageLimitationsForContentEditAction( + ContentInfo $contentInfo, + ?VersionInfo $versionInfo = null, + ?Location $location = null + ): Response { + return new JsonResponse( + $this->getLanguageLimitationsByFunction( + 'edit', + $contentInfo, + $versionInfo, + $location + ) + ); + } + + public function loadLanguageLimitationsForContentReadAction( + ContentInfo $contentInfo, + ?VersionInfo $versionInfo = null, + ?Location $location = null + ): Response { + return new JsonResponse( + $this->getLanguageLimitationsByFunction( + 'read', + $contentInfo, + $versionInfo, + $location + ) + ); + } + + /** + * @return array + */ + private function getLanguageLimitationsByFunction( + string $function, + ContentInfo $contentInfo, + ?VersionInfo $versionInfo = null, + ?Location $location = null + ): array { + $versionInfo ??= $this->contentService->loadVersionInfo($contentInfo); + $location ??= $contentInfo->getMainLocation(); + $targets = []; + + if (null !== $location) { + $targets[] = $location; + } + + return $this->limitationResolver->getLanguageLimitations( + $function, + $contentInfo, + $versionInfo->getLanguages(), + $targets + ); + } +} diff --git a/src/bundle/Resources/config/routing.yaml b/src/bundle/Resources/config/routing.yaml index eaee6cf806..c6f677abc4 100644 --- a/src/bundle/Resources/config/routing.yaml +++ b/src/bundle/Resources/config/routing.yaml @@ -958,3 +958,33 @@ ibexa.asset.upload_image: defaults: _controller: 'Ibexa\Bundle\AdminUi\Controller\AssetController::uploadImageAction' methods: [POST] + +# +# Permissions +# +ibexa.permission.limitation.language.content_create: + path: /permission/limitation/language/content-create/{locationId} + options: + expose: true + controller: 'Ibexa\Bundle\AdminUi\Controller\Permission\LanguageLimitationController::loadLanguageLimitationsForContentCreateAction' + methods: [GET] + requirements: + locationId: \d+ + +ibexa.permission.limitation.language.content_edit: + path: /permission/limitation/language/content-edit/{contentInfoId} + options: + expose: true + controller: 'Ibexa\Bundle\AdminUi\Controller\Permission\LanguageLimitationController::loadLanguageLimitationsForContentEditAction' + methods: [GET] + requirements: + contentInfoId: \d+ + +ibexa.permission.limitation.language.content_read: + path: /permission/limitation/language/content-read/{contentInfoId} + options: + expose: true + controller: 'Ibexa\Bundle\AdminUi\Controller\Permission\LanguageLimitationController::loadLanguageLimitationsForContentReadAction' + methods: [GET] + requirements: + contentInfoId: \d+ diff --git a/src/bundle/Resources/config/services/components.yaml b/src/bundle/Resources/config/services/components.yaml index 6133d6f215..890c5dacf4 100644 --- a/src/bundle/Resources/config/services/components.yaml +++ b/src/bundle/Resources/config/services/components.yaml @@ -39,3 +39,10 @@ services: public: false Ibexa\Contracts\AdminUi\Component\Renderer\RendererInterface: '@Ibexa\AdminUi\Component\Renderer\DefaultRenderer' + + ibexa.adminui.layout.content.after: + parent: Ibexa\AdminUi\Component\TwigComponent + arguments: + $template: '@@ibexadesign/ui/layout_content_after.html.twig' + tags: + - { name: ibexa.admin_ui.component, group: 'layout-content-after' } diff --git a/src/bundle/Resources/config/services/controllers.yaml b/src/bundle/Resources/config/services/controllers.yaml index 8dcc72755a..5f269e81d7 100644 --- a/src/bundle/Resources/config/services/controllers.yaml +++ b/src/bundle/Resources/config/services/controllers.yaml @@ -212,3 +212,9 @@ services: autowire: true Ibexa\Bundle\AdminUi\Controller\User\InvitationController: ~ + + Ibexa\Bundle\AdminUi\Controller\Permission\LanguageLimitationController: + parent: Ibexa\Contracts\AdminUi\Controller\Controller + autowire: true + tags: + - controller.service_arguments diff --git a/src/bundle/Resources/config/services/permissions.yaml b/src/bundle/Resources/config/services/permissions.yaml index 203a4748cb..c152f57d37 100644 --- a/src/bundle/Resources/config/services/permissions.yaml +++ b/src/bundle/Resources/config/services/permissions.yaml @@ -10,3 +10,8 @@ services: alias: Ibexa\AdminUi\Permission\PermissionChecker Ibexa\AdminUi\Permission\LookupLimitationsTransformer: ~ + + Ibexa\AdminUi\Permission\LimitationResolver: ~ + + Ibexa\AdminUi\Permission\LimitationResolverInterface: + alias: Ibexa\AdminUi\Permission\LimitationResolver diff --git a/src/bundle/Resources/config/services/ui_config/common.yaml b/src/bundle/Resources/config/services/ui_config/common.yaml index 316a31fc35..8ba39dd7c1 100644 --- a/src/bundle/Resources/config/services/ui_config/common.yaml +++ b/src/bundle/Resources/config/services/ui_config/common.yaml @@ -96,6 +96,8 @@ services: Ibexa\Bundle\AdminUi\Templating\Twig\ContentTypeIconExtension: ~ + Ibexa\Bundle\AdminUi\Templating\Twig\EmbeddedItemEditFormExtension: ~ + Ibexa\AdminUi\UI\Config\Provider\UserContentTypes: tags: - { name: ibexa.admin_ui.config.provider, key: 'userContentTypes' } diff --git a/src/bundle/Resources/encore/ibexa.js.config.js b/src/bundle/Resources/encore/ibexa.js.config.js index 072de90032..a9919e88e1 100644 --- a/src/bundle/Resources/encore/ibexa.js.config.js +++ b/src/bundle/Resources/encore/ibexa.js.config.js @@ -64,6 +64,7 @@ const layout = [ path.resolve(__dirname, '../public/js/scripts/admin.back.to.top.js'), path.resolve(__dirname, '../public/js/scripts/admin.middle.ellipsis.js'), path.resolve(__dirname, '../public/js/scripts/admin.form.error.js'), + path.resolve(__dirname, '../public/js/scripts/embedded.item.actions'), path.resolve(__dirname, '../public/js/scripts/widgets/flatpickr.js'), ]; const fieldTypes = []; diff --git a/src/bundle/Resources/public/js/scripts/admin.location.view.js b/src/bundle/Resources/public/js/scripts/admin.location.view.js index 498b63e4e5..85ee6cdcba 100644 --- a/src/bundle/Resources/public/js/scripts/admin.location.view.js +++ b/src/bundle/Resources/public/js/scripts/admin.location.view.js @@ -7,6 +7,10 @@ const sortContainer = doc.querySelector('[data-sort-field][data-sort-order]'); const sortField = sortContainer.getAttribute('data-sort-field'); const sortOrder = sortContainer.getAttribute('data-sort-order'); + const emdedItemsUpdateChannel = new BroadcastChannel('ibexa-emded-item-live-update'); + const queryString = window.location.search; + const urlParams = new URLSearchParams(queryString); + const publishedContentId = urlParams.get('publishedContentId'); const handleEditItem = (content, location) => { const contentId = content._id; const locationId = location._id; @@ -190,6 +194,10 @@ }), ); }); + + if (publishedContentId) { + emdedItemsUpdateChannel.postMessage({ contentId: publishedContentId }); + } })( window, window.document, diff --git a/src/bundle/Resources/public/js/scripts/core/multilevel.popup.menu.js b/src/bundle/Resources/public/js/scripts/core/multilevel.popup.menu.js index 9776a69f16..12ba1e7537 100644 --- a/src/bundle/Resources/public/js/scripts/core/multilevel.popup.menu.js +++ b/src/bundle/Resources/public/js/scripts/core/multilevel.popup.menu.js @@ -71,6 +71,7 @@ const isTopBranch = !triggerElement.classList.contains('ibexa-popup-menu__item'); const branchItems = this.getBranchItems(branchElement); const offset = isTopBranch ? [0, 3] : [-8, 2]; + const branchSearchInput = branchElement.querySelector('.ibexa-multilevel-popup-menu__search-input'); const popperInstance = Popper.createPopper(referenceElement ?? triggerElement, branchElement, { placement, @@ -91,6 +92,8 @@ ], }); + branchSearchInput.addEventListener('keyup', this.filterBranchItems, false); + branchSearchInput.addEventListener('input', this.filterBranchItems, false); branchElement.popperInstance = popperInstance; if (isTopBranch) { @@ -134,6 +137,14 @@ }, false, ); + branchElement.addEventListener( + 'ibexa-multilevel-popup-menu:close-branch', + () => { + this.hoveredBranches.delete(branchElement); + this.updateBranchAndParentBranchesOpenState(branchElement); + }, + false, + ); processBranchAfter(branchElement); branchItems.forEach((itemElement) => processBranchItemAfter(itemElement)); @@ -151,6 +162,8 @@ } updateBranchOpenState(branchElement) { + const searchInput = branchElement.querySelector('.ibexa-multilevel-popup-menu__search-input'); + const isSearchInputFilled = !!searchInput?.value; const isSubbranchOpened = (otherBranchElement) => { return ( otherBranchElement && @@ -159,7 +172,7 @@ }; const isBranchOrAnySubbranchHovered = [...this.hoveredItemsBranches, ...this.hoveredBranches].some(isSubbranchOpened); - if (isBranchOrAnySubbranchHovered) { + if (isBranchOrAnySubbranchHovered || isSearchInputFilled) { this.openBranch(branchElement); } else { this.closeWithSubbranches(branchElement); @@ -213,13 +226,21 @@ } generateMenu(menuTree) { - const { triggerElement, groups, placement, fallbackPlacements, processAfterCreated: processBranchAfterCreated } = menuTree; + const { + triggerElement, + groups, + placement, + fallbackPlacements, + processAfterCreated: processBranchAfterCreated, + hasSearch, + } = menuTree; const branchElement = this.generateBranch( { triggerElement, placement, fallbackPlacements, + hasSearch, }, processBranchAfterCreated, ); @@ -250,7 +271,7 @@ } generateBranch(data, processAfterCreated = () => {}) { - const { triggerElement, placement, fallbackPlacements } = data; + const { triggerElement, placement, fallbackPlacements, hasSearch = false } = data; const { branchTemplate } = this.container.dataset; const container = doc.createElement('div'); @@ -259,6 +280,9 @@ container.insertAdjacentHTML('beforeend', renderedItem); const newBranchElement = container.querySelector('.ibexa-multilevel-popup-menu__branch'); + const searchInputWrapper = newBranchElement.querySelector('.ibexa-multilevel-popup-menu__search'); + + searchInputWrapper.classList.toggle('ibexa-multilevel-popup-menu__search--hidden', !hasSearch); processAfterCreated(newBranchElement, data); @@ -304,7 +328,9 @@ processAfterCreated(newGroupElement, data); - branchElement.appendChild(newGroupElement); + const newGroupContainer = branchElement.querySelector('.ibexa-multilevel-popup-menu__groups'); + + newGroupContainer.appendChild(newGroupElement); return newGroupElement; } @@ -365,15 +391,46 @@ return; } + const { target } = event; const isPopupMenuExpanded = !topBranch.classList.contains('ibexa-popup-menu--hidden'); - const isClickInsideTrigger = this.triggerElement.contains(event.target); + const isClickInsideTrigger = this.triggerElement.contains(target); + const isTargetBranch = target.classList.contains('ibexa-multilevel-popup-menu__branch'); + const targetBranch = target.closest('.ibexa-multilevel-popup-menu__branch'); + const isClickInsideMenu = isTargetBranch || !!targetBranch; - if (!isPopupMenuExpanded || isClickInsideTrigger) { + if (!isPopupMenuExpanded || isClickInsideTrigger || isClickInsideMenu) { return; } + const branchesSearchInput = doc.querySelectorAll('.ibexa-multilevel-popup-menu__search-input'); + + branchesSearchInput.forEach((searchInput) => { + if (searchInput.value !== '') { + const searchInputBranch = searchInput.closest('.ibexa-multilevel-popup-menu__branch'); + + searchInput.value = ''; + searchInputBranch.dispatchEvent(new CustomEvent('ibexa-multilevel-popup-menu:close-branch')); + searchInput.dispatchEvent(new Event('input')); + } + }); + this.closeWithSubbranches(topBranch); } + + filterBranchItems(event) { + const searchInput = event.currentTarget; + const branch = searchInput.closest('.ibexa-multilevel-popup-menu__branch'); + const branchItems = branch.querySelectorAll('.ibexa-popup-menu__group > .ibexa-popup-menu__item'); + const phraseLowerCase = searchInput.value.toLowerCase(); + + branchItems.forEach((item) => { + const { searchLabel } = item.dataset; + const searchLabelLowerCase = searchLabel.toLowerCase(); + const hideItem = !searchLabelLowerCase.includes(phraseLowerCase); + + item.classList.toggle('ibexa-popup-menu__item--hidden', hideItem); + }); + } } ibexa.addConfig('core.MultilevelPopupMenu', MultilevelPopupMenu); diff --git a/src/bundle/Resources/public/js/scripts/embedded.item.actions.js b/src/bundle/Resources/public/js/scripts/embedded.item.actions.js new file mode 100644 index 0000000000..655962c696 --- /dev/null +++ b/src/bundle/Resources/public/js/scripts/embedded.item.actions.js @@ -0,0 +1,316 @@ +(function (global, doc, ibexa, Routing, Translator, Popper) { + const MIN_ITEMS_NUMBER_TO_SHOW_SEARCH = 10; + const MENU_PROPS = { + placement: 'bottom-start', + fallbackPlacements: ['top-end', 'top-start'], + }; + const token = document.querySelector('meta[name="CSRF-Token"]').content; + const siteaccess = document.querySelector('meta[name="SiteAccess"]').content; + const metaLanguageCode = document.querySelector('meta[name="LanguageCode"]')?.content; + const previewLanguageCode = metaLanguageCode ?? ibexa.adminUiConfig.languages.priority[0]; + const adminUiLanguages = ibexa.adminUiConfig.languages.mappings; + const emdedItemsUpdateChannel = new BroadcastChannel('ibexa-emded-item-live-update'); + const editEmbeddedItemForm = doc.querySelector('[name="embedded_item_edit"]'); + const actionsMenuTriggerBtns = doc.querySelectorAll('.ibexa-embedded-item-actions__menu-trigger-btn'); + const updateNode = ({ node, value, isMiddleEllipsis }) => { + if (!isMiddleEllipsis) { + node.textContent = value; + + return; + } + + const middleEllipsisNode = node.querySelector('.ibexa-middle-ellipsis'); + const middleEllipsisNameStartNode = node.querySelector( + '.ibexa-middle-ellipsis__name--start .ibexa-middle-ellipsis__name-ellipsized', + ); + const middleEllipsisNameEndNode = node.querySelector('.ibexa-middle-ellipsis__name--end .ibexa-middle-ellipsis__name-ellipsized'); + + middleEllipsisNode.title = value; + middleEllipsisNameStartNode.textContent = value; + middleEllipsisNameEndNode.textContent = value; + + ibexa.helpers.ellipsis.middle.parse(node); + }; + const updateNodes = async (contentId) => { + const nodesToUpdate = doc.querySelectorAll(`[data-ibexa-update-content-id="${contentId}"]`); + + if (!nodesToUpdate) { + return; + } + + const contentData = await loadContentData(contentId); + + [...nodesToUpdate].forEach((nodeToUpdate) => { + let sourceValue = contentData; + const { ibexaUpdateSourceDataPath } = nodeToUpdate.dataset; + const updateSourceDataPathArray = ibexaUpdateSourceDataPath.split('.'); + + for (const pathLevelIndex in updateSourceDataPathArray) { + const pathLevel = updateSourceDataPathArray[pathLevelIndex]; + + sourceValue = sourceValue[pathLevel]; + } + + if (sourceValue) { + const { ibexaUpdateMiddleEllipsis } = nodeToUpdate.dataset; + + updateNode({ + node: nodeToUpdate, + value: sourceValue, + isMiddleEllipsis: ibexaUpdateMiddleEllipsis, + }); + } + }); + }; + const loadContentData = async (contentId) => { + try { + const loadContentRequest = new Request(`/api/ibexa/v2/content/objects/${contentId}`, { + method: 'GET', + headers: { + Accept: 'application/vnd.ibexa.api.Content+json', + 'X-Siteaccess': siteaccess, + 'X-CSRF-Token': token, + }, + mode: 'same-origin', + credentials: 'same-origin', + }); + const response = await fetch(loadContentRequest); + + return ibexa.helpers.request.getJsonFromResponse(response); + } catch (error) { + ibexa.helpers.notification.showErrorNotification(error); + } + }; + const editContent = ({ contentId, locationId, languageCode }) => { + if (!contentId || !locationId || !languageCode) { + return; + } + + const contentInfoInput = editEmbeddedItemForm.querySelector('[name="embedded_item_edit[content_info]"]'); + const locationInput = editEmbeddedItemForm.querySelector('[name="embedded_item_edit[location]"]'); + const languageInput = editEmbeddedItemForm.querySelector(`[name="embedded_item_edit[language]"][value="${languageCode}"]`); + + contentInfoInput.value = contentId; + locationInput.value = locationId; + languageInput.click(); + + editEmbeddedItemForm.submit(); + }; + const generateGoToActionItem = ({ contentId, locationId, productCode }) => { + const href = productCode + ? Routing.generate('ibexa.product_catalog.product.view', { + productCode, + languageCode: previewLanguageCode, + }) + : Routing.generate('ibexa.content.translation.view', { + contentId, + locationId, + languageCode: previewLanguageCode, + }); + + return { + label: Translator.trans(/*@Desc("Go to content")*/ 'embedded_items.action.go_to_label', {}, 'ibexa_content'), + href, + }; + }; + const generateEditActionItem = ({ contentId, locationId, productCode, languages }) => { + if (languages.length > 1) { + return { + label: Translator.trans(/*@Desc("Edit")*/ 'embedded_items.action.edit', {}, 'ibexa_content'), + branch: { + hasSearch: languages.length >= MIN_ITEMS_NUMBER_TO_SHOW_SEARCH, + groups: [ + { + id: 'edit-group', + items: languages.map(({ languageCode, name }) => { + const languageEditAction = productCode + ? { + href: Routing.generate('ibexa.product_catalog.product.edit', { + productCode, + languageCode, + }), + } + : { + onClick: () => editContent({ contentId, locationId, languageCode }), + }; + + return { + label: name, + ...languageEditAction, + }; + }), + }, + ], + }, + }; + } + + const editAction = productCode + ? { + href: Routing.generate('ibexa.product_catalog.product.edit', { + productCode, + languageCode: languages[0].languageCode, + }), + } + : { + onClick: () => + editContent({ + contentId, + locationId, + languageCode: languages[0].languageCode, + }), + }; + + return { + label: Translator.trans(/*@Desc("Edit")*/ 'embedded_items.action.edit', {}, 'ibexa_content'), + ...editAction, + }; + }; + const generateMenuTreeItems = ({ contentId, locationId, productCode, languages }) => { + const goToItem = generateGoToActionItem({ contentId, locationId, productCode }); + const editItem = generateEditActionItem({ contentId, locationId, productCode, languages }); + + return { + groups: [ + { + id: 'default', + items: [goToItem, editItem], + }, + ], + }; + }; + const getLanguagesData = async ({ contentId, initialFunc = () => {}, callbackFunc = () => {} }) => { + try { + initialFunc(); + + const url = window.Routing.generate('ibexa.permission.limitation.language.content_edit', { contentInfoId: contentId }); + const request = new Request(url, { + method: 'GET', + headers: { 'X-CSRF-Token': token }, + mode: 'same-origin', + credentials: 'same-origin', + }); + const response = await fetch(request); + const data = await ibexa.helpers.request.getJsonFromResponse(response); + + callbackFunc(); + + return data.filter((language) => language.hasAccess); + } catch (error) { + ibexa.helpers.notification.showErrorNotification(error); + } + }; + const getMenuData = ({ container, event }) => { + const { contentId, locationId, productCode, languageCodes = [] } = container ? container.dataset : event.detail; + const parsedLanguageCodes = typeof languageCodes === 'string' ? JSON.parse(languageCodes) : languageCodes; + const languages = parsedLanguageCodes.map((languageCode) => ({ + languageCode, + name: adminUiLanguages[languageCode].name, + })); + + return { + contentId: parseInt(contentId, 10), + locationId: parseInt(locationId, 10), + productCode, + languages, + }; + }; + const createMenu = async ({ triggerElement, container, contentId, locationId, productCode, languages }) => { + triggerElement.dataset.isMenuAttached = 1; + + const mainContainer = container.closest('.ibexa-embedded-item-actions'); + const menuLoader = mainContainer.querySelector('.ibexa-embedded-item-actions__loader'); + const askForLanguagesData = Object.keys(languages).length !== 1; + const languagesData = askForLanguagesData + ? await getLanguagesData({ + contentId, + initialFunc: showLoader.bind(null, { triggerElement, menuLoader }), + callbackFunc: hideLoader.bind(null, { menuLoader }), + }) + : languages; + const menuItems = generateMenuTreeItems({ contentId, locationId, productCode, languages: languagesData }); + const menuInstance = new ibexa.core.MultilevelPopupMenu({ + container, + triggerElement, + }); + + menuInstance.init(); + menuInstance.generateMenu({ + triggerElement, + ...MENU_PROPS, + ...menuItems, + }); + + triggerElement.click(); + }; + const showLoader = ({ triggerElement, menuLoader }) => { + Popper.createPopper(triggerElement, menuLoader, { + placement: MENU_PROPS.placement, + modifiers: [ + { + name: 'flip', + enabled: true, + options: { + fallbackPlacements: MENU_PROPS.fallbackPlacements, + }, + }, + ], + }); + + menuLoader.classList.remove('ibexa-popup-menu--hidden'); + }; + const hideLoader = ({ menuLoader }) => { + menuLoader.classList.add('ibexa-popup-menu--hidden'); + }; + + actionsMenuTriggerBtns.forEach((actionsMenuTriggerBtn) => { + actionsMenuTriggerBtn.addEventListener( + 'click', + (event) => { + const isMenuAttached = !!parseInt(actionsMenuTriggerBtn.dataset.isMenuAttached, 10); + + if (!isMenuAttached) { + event.preventDefault(); + + const menuMainContainer = actionsMenuTriggerBtn.closest('.ibexa-embedded-item-actions'); + const menuContainer = menuMainContainer.querySelector('.ibexa-embedded-item-actions__menu'); + const menuData = getMenuData({ container: menuContainer }); + + createMenu({ + triggerElement: actionsMenuTriggerBtn, + container: menuContainer, + ...menuData, + }); + } + }, + false, + ); + }); + + doc.body.addEventListener('ibexa-embedded-item:create-dynamic-menu', (event) => { + const menuData = getMenuData({ event }); + const { menuTriggerElement, menuContainer } = event.detail; + + menuTriggerElement.addEventListener( + 'click', + () => { + const isMenuAttached = !!parseInt(menuTriggerElement.dataset.isMenuAttached, 10); + + if (!isMenuAttached) { + event.preventDefault(); + + createMenu({ + triggerElement: menuTriggerElement, + container: menuContainer, + ...menuData, + }); + } + }, + false, + ); + }); + + emdedItemsUpdateChannel.addEventListener('message', (event) => { + updateNodes(event.data.contentId); + }); +})(window, document, window.ibexa, window.Routing, window.Translator, window.Popper); diff --git a/src/bundle/Resources/public/js/scripts/fieldType/ezobjectrelationlist.js b/src/bundle/Resources/public/js/scripts/fieldType/ezobjectrelationlist.js index f0fe807ac1..82f94ec72d 100644 --- a/src/bundle/Resources/public/js/scripts/fieldType/ezobjectrelationlist.js +++ b/src/bundle/Resources/public/js/scripts/fieldType/ezobjectrelationlist.js @@ -77,9 +77,33 @@ const { escapeHTML } = ibexa.helpers.text; const itemNodes = relationsContainer.querySelectorAll('.ibexa-relations__item'); const itemNode = itemNodes[itemNodes.length - 1]; - - itemNode.setAttribute('data-content-id', escapeHTML(item.ContentInfo.Content._id)); + const contentId = escapeHTML(item.ContentInfo.Content._id); + const locationId = item.id; + const { VersionInfo } = item.ContentInfo.Content.CurrentVersion.Version; + const currentVersionNo = VersionInfo.versionNo; + const languageCodes = VersionInfo.VersionTranslationInfo.Language.map((language) => language.languageCode); + const itemActionsMenuContainer = itemNode.querySelector('.ibexa-embedded-item-actions__menu'); + const itemActionsTriggerElement = itemNode.querySelector('.ibexa-embedded-item-actions__menu-trigger-btn'); + const itemNodeNameCell = itemNode.querySelector('.ibexa-relations__item-name'); + + itemNode.dataset.contentId = contentId; itemNode.querySelector('.ibexa-relations__table-action--remove-item').addEventListener('click', removeItem, false); + + itemNodeNameCell.dataset.ibexaUpdateContentId = contentId; + itemNodeNameCell.dataset.ibexaUpdateSourceDataPath = 'Content.Name'; + + doc.body.dispatchEvent( + new CustomEvent('ibexa-embedded-item:create-dynamic-menu', { + detail: { + contentId, + locationId, + languageCodes, + versionNo: currentVersionNo, + menuTriggerElement: itemActionsTriggerElement, + menuContainer: itemActionsMenuContainer, + }, + }), + ); }); ibexa.helpers.tooltips.parse(); @@ -143,7 +167,7 @@ const { formatShortDateTime } = ibexa.helpers.timezone; const contentTypeName = ibexa.helpers.contentType.getContentTypeName(item.ContentInfo.Content.ContentTypeInfo.identifier); const contentName = escapeHTML(item.ContentInfo.Content.TranslatedName); - const contentId = item.ContentInfo.Content._id; + const contentId = escapeHTML(item.ContentInfo.Content._id); const { rowTemplate } = relationsWrapper.dataset; return rowTemplate diff --git a/src/bundle/Resources/public/scss/_multilevel-popup-menu.scss b/src/bundle/Resources/public/scss/_multilevel-popup-menu.scss new file mode 100644 index 0000000000..e141a5dda6 --- /dev/null +++ b/src/bundle/Resources/public/scss/_multilevel-popup-menu.scss @@ -0,0 +1,19 @@ +.ibexa-multilevel-popup-menu { + &__search { + margin-bottom: calculateRem(4px); + padding: 0 calculateRem(8px); + + &--hidden { + display: none; + } + } + + &__search-input { + border-radius: $ibexa-border-radius; + } + + &__groups { + max-height: calculateRem(390px); + overflow-y: auto; + } +} diff --git a/src/bundle/Resources/public/scss/fieldType/edit/_ezobjectrelationlist.scss b/src/bundle/Resources/public/scss/fieldType/edit/_ezobjectrelationlist.scss index a4e077fa75..95cf838d92 100644 --- a/src/bundle/Resources/public/scss/fieldType/edit/_ezobjectrelationlist.scss +++ b/src/bundle/Resources/public/scss/fieldType/edit/_ezobjectrelationlist.scss @@ -61,6 +61,13 @@ &__table-action--remove-item { padding: calculateRem(4px); } + + &__actions-cell { + display: flex; + gap: calculateRem(4px); + justify-content: center; + align-items: center; + } } .btn { diff --git a/src/bundle/Resources/public/scss/ibexa.scss b/src/bundle/Resources/public/scss/ibexa.scss index e5ae89c97e..d0d6278233 100644 --- a/src/bundle/Resources/public/scss/ibexa.scss +++ b/src/bundle/Resources/public/scss/ibexa.scss @@ -90,6 +90,7 @@ @import 'links'; @import 'footer'; @import 'popup-menu'; +@import 'multilevel-popup-menu'; @import 'header-user-menu'; @import 'main-header'; @import 'main-container'; diff --git a/src/bundle/Resources/translations/ibexa_content.en.xliff b/src/bundle/Resources/translations/ibexa_content.en.xliff index 4c04367cf2..5cac0dfa24 100644 --- a/src/bundle/Resources/translations/ibexa_content.en.xliff +++ b/src/bundle/Resources/translations/ibexa_content.en.xliff @@ -61,6 +61,16 @@ Location: %location% key: editing_details + + Edit + Edit + key: embedded_items.action.edit + + + Go to content + Go to content + key: embedded_items.action.go_to_label + Back Back diff --git a/src/bundle/Resources/translations/ibexa_multilevel_popup_menu.en.xliff b/src/bundle/Resources/translations/ibexa_multilevel_popup_menu.en.xliff new file mode 100644 index 0000000000..f428cffcf6 --- /dev/null +++ b/src/bundle/Resources/translations/ibexa_multilevel_popup_menu.en.xliff @@ -0,0 +1,16 @@ + + + +
+ + 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. +
+ + + Search... + Search... + key: search.placeholder + + +
+
diff --git a/src/bundle/Resources/views/themes/admin/ui/component/embedded_item_actions/embedded_item_actions.html.twig b/src/bundle/Resources/views/themes/admin/ui/component/embedded_item_actions/embedded_item_actions.html.twig new file mode 100644 index 0000000000..5ff74f5585 --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/ui/component/embedded_item_actions/embedded_item_actions.html.twig @@ -0,0 +1,46 @@ +
+ {% block loader %} +
    +
  • + + + +
  • +
+ {% endblock %} + + {% block embedded_item_menu_trigger %} + + {% endblock %} + + {% block embedded_item_menu %} + {% include '@ibexadesign/ui/component/multilevel_popup_menu/multilevel_popup_menu.html.twig' with { + is_custom_init: is_custom_init|default(true), + attr: { + 'data-content-id': content_id|default(''), + 'data-location-id': location_id|default(''), + 'data-version-no': version_no|default(''), + 'data-product-code': product_code|default(''), + 'data-language-codes': language_codes|default([])|json_encode, + class: attr.class|default('ibexa-embedded-item-actions__menu') + }, + items_container_attr: { + 'data-item-template-link': include('@ibexadesign/ui/component/multilevel_popup_menu/multilevel_popup_menu_item.html.twig', { + is_button: false, + label: '{{ label }}', + action_attr: { + target: '_blank' + } + }) + } + } only %} + {% endblock %} +
diff --git a/src/bundle/Resources/views/themes/admin/ui/component/multilevel_popup_menu/multilevel_popup_menu_branch.html.twig b/src/bundle/Resources/views/themes/admin/ui/component/multilevel_popup_menu/multilevel_popup_menu_branch.html.twig index f9bbd8c76f..0580c759a2 100644 --- a/src/bundle/Resources/views/themes/admin/ui/component/multilevel_popup_menu/multilevel_popup_menu_branch.html.twig +++ b/src/bundle/Resources/views/themes/admin/ui/component/multilevel_popup_menu/multilevel_popup_menu_branch.html.twig @@ -8,17 +8,29 @@ }) -%} {%- block branch -%} + {% trans_default_domain 'ibexa_multilevel_popup_menu' %} +
+ + {%- block branch_groups -%} - {%- for group in groups -%} - {%- set group_params = group|merge({ - items: group.items|default([]), - group_attr: group.group_attr|default({}), - }) -%} +
+ {%- for group in groups -%} + {%- set group_params = group|merge({ + items: group.items|default([]), + group_attr: group.group_attr|default({}), + }) -%} - {%- include '@ibexadesign/ui/component/multilevel_popup_menu/multilevel_popup_menu_group.html.twig' - with group_params -%} - {%- endfor -%} + {%- include '@ibexadesign/ui/component/multilevel_popup_menu/multilevel_popup_menu_group.html.twig' + with group_params -%} + {%- endfor -%} +
{%- endblock -%}
{%- endblock -%} diff --git a/src/bundle/Resources/views/themes/admin/ui/component/multilevel_popup_menu/multilevel_popup_menu_item.html.twig b/src/bundle/Resources/views/themes/admin/ui/component/multilevel_popup_menu/multilevel_popup_menu_item.html.twig index 6310c7c82e..2abad5f78b 100644 --- a/src/bundle/Resources/views/themes/admin/ui/component/multilevel_popup_menu/multilevel_popup_menu_item.html.twig +++ b/src/bundle/Resources/views/themes/admin/ui/component/multilevel_popup_menu/multilevel_popup_menu_item.html.twig @@ -6,6 +6,7 @@ class: ('ibexa-popup-menu__item ibexa-multilevel-popup-menu__item ' ~ (branch|default(null) ? 'ibexa-popup-menu__item--has-subitems ') ~ item_attr.class|default(''))|trim, + 'data-search-label': label, }) -%} {%- set action_attr = action_attr|default({})|merge({ diff --git a/src/bundle/Resources/views/themes/admin/ui/field_type/edit/ezrichtext.html.twig b/src/bundle/Resources/views/themes/admin/ui/field_type/edit/ezrichtext.html.twig index 15d8965bd9..103cdf4361 100644 --- a/src/bundle/Resources/views/themes/admin/ui/field_type/edit/ezrichtext.html.twig +++ b/src/bundle/Resources/views/themes/admin/ui/field_type/edit/ezrichtext.html.twig @@ -16,4 +16,7 @@ {% endif %} + {% embed '@ibexadesign/ui/component/embedded_item_actions/embedded_item_actions.html.twig' only %} + {% block embedded_item_menu_trigger %}{% endblock %} + {% endembed %} {%- endblock -%} diff --git a/src/bundle/Resources/views/themes/admin/ui/field_type/edit/relation_base.html.twig b/src/bundle/Resources/views/themes/admin/ui/field_type/edit/relation_base.html.twig index 38dfe8cc71..962b108df9 100644 --- a/src/bundle/Resources/views/themes/admin/ui/field_type/edit/relation_base.html.twig +++ b/src/bundle/Resources/views/themes/admin/ui/field_type/edit/relation_base.html.twig @@ -7,7 +7,18 @@ {% set allowed_content_types = form.parent.vars.value.fieldDefinition.fieldSettings.selectionContentTypes %} {% set helper = helper|default('') %} {% set readonly = attr.readonly|default(false) %} - + {% set remove_item_btn %} + + {% endset %} {% set col_raw_checkbox_template %} {% endset %} - {% set col_raw_actions %} - + {% set col_raw_actions_template %} + {{ remove_item_btn }} + + {% include '@ibexadesign/ui/component/embedded_item_actions/embedded_item_actions.html.twig' only %} {% endset %} {% set body_row_cols_template = [] %} @@ -54,7 +58,7 @@ {% set body_row_cols_template = body_row_cols_template|merge([ { content: '{{ content_name }}', - attr: { class: 'ibexa-relations__item-name' }, + class: 'ibexa-relations__item-name', }, { content: '{{ content_type_name }}' }, { content: '{{ published_date }}' }, @@ -72,9 +76,9 @@ {% set body_row_cols_template = body_row_cols_template|merge([ { - content: col_raw_actions, + content: col_raw_actions_template, raw: true, - has_icon: true, + class: 'ibexa-relations__actions-cell' }, ]) %} @@ -84,13 +88,25 @@ class: 'ibexa-relations__item', }) }} {% endset %} +
{% set body_rows = [] %} + {% for relation in relations %} {% set body_row_cols = [] %} + {% set col_raw_actions %} + {{ remove_item_btn }} + + {% include '@ibexadesign/ui/component/embedded_item_actions/embedded_item_actions.html.twig' with { + content_id: relation.contentId, + location_id: relation.contentInfo.mainLocationId, + version_no: relation.contentInfo.currentVersionNo, + } only %} + {% endset %} + {% if relation.contentInfo is not null and relation.contentType is not null %} {% set col_raw_checkbox %} {% set attr = attr|merge({'hidden': 'hidden'}) %} {{ block('form_widget') }} - {% endblock %} diff --git a/src/bundle/Resources/views/themes/admin/ui/form_fields.html.twig b/src/bundle/Resources/views/themes/admin/ui/form_fields.html.twig index a599b77d89..370dcb9168 100644 --- a/src/bundle/Resources/views/themes/admin/ui/form_fields.html.twig +++ b/src/bundle/Resources/views/themes/admin/ui/form_fields.html.twig @@ -285,6 +285,9 @@
{% endif %} + {% embed '@ibexadesign/ui/component/embedded_item_actions/embedded_item_actions.html.twig' only %} + {% block embedded_item_menu_trigger %}{% endblock %} + {% endembed %} {%- endblock -%} diff --git a/src/bundle/Resources/views/themes/admin/ui/layout.html.twig b/src/bundle/Resources/views/themes/admin/ui/layout.html.twig index 07b5a39a74..dcd0b6c36c 100644 --- a/src/bundle/Resources/views/themes/admin/ui/layout.html.twig +++ b/src/bundle/Resources/views/themes/admin/ui/layout.html.twig @@ -221,6 +221,8 @@ {% endblock %} {% endif %} + {{ ibexa_render_component_group('layout-content-after') }} + {{ encore_entry_script_tags('ibexa-admin-ui-layout-js', null, 'ibexa') }} {{ encore_entry_script_tags('ibexa-admin-ui-udw-tabs-js', null, 'ibexa') }} {{ encore_entry_script_tags('ibexa-admin-ui-udw-extras-js', null, 'ibexa') }} diff --git a/src/bundle/Resources/views/themes/admin/ui/layout_content_after.html.twig b/src/bundle/Resources/views/themes/admin/ui/layout_content_after.html.twig new file mode 100644 index 0000000000..3e2b800863 --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/ui/layout_content_after.html.twig @@ -0,0 +1,24 @@ +
+ {% set form = ibexa_render_embedded_item_edit_form() %} + + {{ form_start(form, { + attr: { target: '_blank' } + }) }} + {{ form_widget(form.content_info, { 'attr': { + 'hidden': 'hidden', + 'class': 'ibexa-embedded-item-edit__form-field ibexa-embedded-item-edit__form-field--content-info' + } }) }} + {{ form_widget(form.version_info, { 'attr': { + 'hidden': 'hidden', + 'class': 'ibexa-embedded-item-edit__form-field ibexa-embedded-item-edit__form-field--version-info' + } }) }} + {{ form_widget(form.language, { 'attr': { + 'hidden': 'hidden', + 'class': 'ibexa-embedded-item-edit__form-field ibexa-embedded-item-edit__form-field--language' + } }) }} + {{ form_widget(form.location, { 'attr': { + 'hidden': 'hidden', + 'class': 'ibexa-embedded-item-edit__form-field ibexa-embedded-item-edit__form-field--location' + } }) }} + {{ form_end(form) }} +
diff --git a/src/bundle/Templating/Twig/EmbeddedItemEditFormExtension.php b/src/bundle/Templating/Twig/EmbeddedItemEditFormExtension.php new file mode 100644 index 0000000000..4e00a8aa89 --- /dev/null +++ b/src/bundle/Templating/Twig/EmbeddedItemEditFormExtension.php @@ -0,0 +1,55 @@ +formFactory = $formFactory; + $this->router = $router; + } + + public function getFunctions(): array + { + return [ + new TwigFunction( + 'ibexa_render_embedded_item_edit_form', + [$this, 'renderEmbeddedItemEditForm'] + ), + ]; + } + + public function renderEmbeddedItemEditForm(): FormView + { + return $this->formFactory->contentEdit( + new ContentEditData(), + 'embedded_item_edit', + [ + 'action' => $this->router->generate('ibexa.content.edit'), + 'attr' => [ + 'class' => 'ibexa-embedded-item-edit', + ], + ] + )->createView(); + } +} diff --git a/src/contracts/Permission/PermissionCheckerInterface.php b/src/contracts/Permission/PermissionCheckerInterface.php index 23befd3c65..0f4e68cb30 100644 --- a/src/contracts/Permission/PermissionCheckerInterface.php +++ b/src/contracts/Permission/PermissionCheckerInterface.php @@ -19,20 +19,26 @@ public function getRestrictions(array $hasAccess, string $class): array; public function canCreateInLocation(Location $location, $hasAccess): bool; /** - * @internal - * * @throws \Ibexa\Contracts\Core\Repository\Exceptions\BadStateException * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + * + * @internal + * + * @deprecated 4.6.0 The "\Ibexa\Contracts\AdminUi\Permission\PermissionCheckerInterface::getContentCreateLimitations()" method is deprecated, will be removed in 5.0. + * Use { @see \Ibexa\AdminUi\Permission\LimitationResolverInterface::getContentCreateLimitations() } instead. */ public function getContentCreateLimitations(Location $parentLocation): LookupLimitationResult; /** - * @internal - * * @throws \Ibexa\Contracts\Core\Repository\Exceptions\BadStateException * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + * + * @internal + * + * @deprecated 4.6.0 The "\Ibexa\Contracts\AdminUi\Permission\PermissionCheckerInterface::getContentUpdateLimitations()" method is deprecated, will be removed in 5.0. + * Use { @see \Ibexa\AdminUi\Permission\LimitationResolverInterface::getContentUpdateLimitations } instead. */ public function getContentUpdateLimitations(Location $parentLocation): LookupLimitationResult; } diff --git a/src/lib/Permission/LimitationResolver.php b/src/lib/Permission/LimitationResolver.php new file mode 100644 index 0000000000..dd1ceadae6 --- /dev/null +++ b/src/lib/Permission/LimitationResolver.php @@ -0,0 +1,173 @@ +contentService = $contentService; + $this->contentTypeService = $contentTypeService; + $this->languageService = $languageService; + $this->locationService = $locationService; + $this->lookupLimitationsTransformer = $lookupLimitationsTransformer; + $this->permissionResolver = $permissionResolver; + } + + public function getContentCreateLimitations(Location $parentLocation): LookupLimitationResult + { + $contentInfo = $parentLocation->getContentInfo(); + $contentType = $contentInfo->getContentType(); + $contentCreateStruct = $this->contentService->newContentCreateStruct($contentType, $contentInfo->getMainLanguageCode()); + $contentCreateStruct->sectionId = $contentInfo->getSection(); + $locationCreateStruct = $this->locationService->newLocationCreateStruct($parentLocation->id); + + $versionBuilder = new VersionBuilder(); + $versionBuilder->translateToAnyLanguageOf($this->getActiveLanguageCodes()); + $versionBuilder->createFromAnyContentTypeOf($this->getContentTypeIds()); + + return $this->permissionResolver->lookupLimitations( + 'content', + 'create', + $contentCreateStruct, + [$versionBuilder->build(), $locationCreateStruct], + [Limitation::CONTENTTYPE, Limitation::LANGUAGE] + ); + } + + public function getContentUpdateLimitations(Location $parentLocation): LookupLimitationResult + { + $versionBuilder = new VersionBuilder(); + $versionBuilder->translateToAnyLanguageOf($this->getActiveLanguageCodes()); + $versionBuilder->createFromAnyContentTypeOf($this->getContentTypeIds()); + + return $this->permissionResolver->lookupLimitations( + 'content', + 'edit', + $parentLocation->getContentInfo(), + [$versionBuilder->build(), $parentLocation], + [Limitation::CONTENTTYPE, Limitation::LANGUAGE] + ); + } + + public function getLanguageLimitations( + string $function, + ValueObject $valueObject, + iterable $languages = [], + array $targets = [] + ): array { + $languages = !empty($languages) ? $languages : $this->languageService->loadLanguages(); + $versionBuilder = new VersionBuilder(); + $versionBuilder->translateToAnyLanguageOf($this->getActiveLanguageCodes($languages)); + + $lookupLimitations = $this->permissionResolver->lookupLimitations( + 'content', + $function, + $valueObject, + array_merge( + $targets, + [$versionBuilder->build()] + ), + [Limitation::LANGUAGE] + ); + + $limitationLanguageCodes = $this->lookupLimitationsTransformer->getFlattenedLimitationsValues($lookupLimitations); + $languageLimitations = []; + foreach ($languages as $language) { + $languageLimitations[] = [ + 'languageCode' => $language->getLanguageCode(), + 'name' => $language->getName(), + 'hasAccess' => $lookupLimitations->hasAccess && $this->hasAccessToLanguage($language, $limitationLanguageCodes), + ]; + } + + return $languageLimitations; + } + + /** + * @param array $limitationLanguageCodes + */ + private function hasAccessToLanguage(Language $language, array $limitationLanguageCodes): bool + { + return $language->isEnabled() + && ( + empty($limitationLanguageCodes) + || in_array($language->getLanguageCode(), $limitationLanguageCodes, true) + ); + } + + /** + * @return array + */ + private function getContentTypeIds(): array + { + $contentTypeIds = []; + + $contentTypeGroups = $this->contentTypeService->loadContentTypeGroups(); + foreach ($contentTypeGroups as $contentTypeGroup) { + $contentTypes = $this->contentTypeService->loadContentTypes($contentTypeGroup); + foreach ($contentTypes as $contentType) { + $contentTypeIds[] = $contentType->id; + } + } + + return $contentTypeIds; + } + + /** + * @param iterable<\Ibexa\Contracts\Core\Repository\Values\Content\Language>|null $languages + * + * @return array + */ + private function getActiveLanguageCodes(?iterable $languages = null): array + { + $languages ??= $this->languageService->loadLanguages(); + $languageCodes = []; + foreach ($languages as $language) { + if ($language->isEnabled()) { + $languageCodes[] = $language->getLanguageCode(); + } + } + + return $languageCodes; + } +} diff --git a/src/lib/Permission/LimitationResolverInterface.php b/src/lib/Permission/LimitationResolverInterface.php new file mode 100644 index 0000000000..7ba4692e86 --- /dev/null +++ b/src/lib/Permission/LimitationResolverInterface.php @@ -0,0 +1,50 @@ + $languages + * @param array<\Ibexa\Contracts\Core\Repository\Values\ValueObject> $targets + * + * @return array + */ + public function getLanguageLimitations( + string $function, + ValueObject $valueObject, + iterable $languages = [], + array $targets = [] + ): array; +} diff --git a/src/lib/Permission/PermissionChecker.php b/src/lib/Permission/PermissionChecker.php index 1c7b2f4e99..6728b7d682 100644 --- a/src/lib/Permission/PermissionChecker.php +++ b/src/lib/Permission/PermissionChecker.php @@ -9,16 +9,9 @@ namespace Ibexa\AdminUi\Permission; use Ibexa\Contracts\AdminUi\Permission\PermissionCheckerInterface; -use Ibexa\Contracts\Core\Limitation\Target\Builder\VersionBuilder; -use Ibexa\Contracts\Core\Repository\ContentService; -use Ibexa\Contracts\Core\Repository\ContentTypeService; -use Ibexa\Contracts\Core\Repository\LanguageService; -use Ibexa\Contracts\Core\Repository\LocationService; use Ibexa\Contracts\Core\Repository\PermissionResolver; use Ibexa\Contracts\Core\Repository\UserService; -use Ibexa\Contracts\Core\Repository\Values\Content\Language; use Ibexa\Contracts\Core\Repository\Values\Content\Location; -use Ibexa\Contracts\Core\Repository\Values\User\Limitation; use Ibexa\Contracts\Core\Repository\Values\User\Limitation\LocationLimitation; use Ibexa\Contracts\Core\Repository\Values\User\Limitation\ParentContentTypeLimitation; use Ibexa\Contracts\Core\Repository\Values\User\Limitation\ParentDepthLimitation; @@ -39,40 +32,16 @@ class PermissionChecker implements PermissionCheckerInterface /** @var \Ibexa\Contracts\Core\Repository\UserService */ private $userService; - /** @var \Ibexa\Contracts\Core\Repository\LocationService */ - private $locationService; + private LimitationResolverInterface $limitationResolver; - /** @var \Ibexa\Contracts\Core\Repository\ContentTypeService */ - private $contentTypeService; - - /** @var \Ibexa\Contracts\Core\Repository\ContentService */ - private $contentService; - - /** @var \Ibexa\Contracts\Core\Repository\LanguageService */ - private $languageService; - - /** - * @param \Ibexa\Contracts\Core\Repository\PermissionResolver $permissionResolver - * @param \Ibexa\Contracts\Core\Repository\UserService $userService - * @param \Ibexa\Contracts\Core\Repository\LocationService $locationService - * @param \Ibexa\Contracts\Core\Repository\ContentService $contentService - * @param \Ibexa\Contracts\Core\Repository\ContentTypeService $contentTypeService - * @param \Ibexa\Contracts\Core\Repository\LanguageService $languageService - */ public function __construct( PermissionResolver $permissionResolver, - UserService $userService, - LocationService $locationService, - ContentService $contentService, - ContentTypeService $contentTypeService, - LanguageService $languageService + LimitationResolverInterface $limitationResolver, + UserService $userService ) { $this->permissionResolver = $permissionResolver; + $this->limitationResolver = $limitationResolver; $this->userService = $userService; - $this->locationService = $locationService; - $this->contentTypeService = $contentTypeService; - $this->contentService = $contentService; - $this->languageService = $languageService; } /** @@ -190,39 +159,24 @@ public function canCreateInLocation(Location $location, $hasAccess): bool public function getContentCreateLimitations(Location $parentLocation): LookupLimitationResult { - $contentType = $this->contentTypeService->loadContentType($parentLocation->contentInfo->contentTypeId); - $contentCreateStruct = $this->contentService->newContentCreateStruct($contentType, $parentLocation->contentInfo->mainLanguageCode); - $contentCreateStruct->sectionId = $parentLocation->contentInfo->sectionId; - $locationCreateStruct = $this->locationService->newLocationCreateStruct($parentLocation->id); - - $versionBuilder = new VersionBuilder(); - $versionBuilder->translateToAnyLanguageOf($this->getActiveLanguageCodes()); - $versionBuilder->createFromAnyContentTypeOf($this->getContentTypeIds()); - - return $this->permissionResolver->lookupLimitations( - 'content', - 'create', - $contentCreateStruct, - [$versionBuilder->build(), $locationCreateStruct], - [Limitation::CONTENTTYPE, Limitation::LANGUAGE] + trigger_deprecation( + 'ibexa/admin-ui', + '4.6', + sprintf('The %s() method is deprecated, will be removed in 5.0.', __METHOD__) ); + + return $this->limitationResolver->getContentCreateLimitations($parentLocation); } public function getContentUpdateLimitations(Location $location): LookupLimitationResult { - $contentInfo = $location->getContentInfo(); - - $versionBuilder = new VersionBuilder(); - $versionBuilder->translateToAnyLanguageOf($this->getActiveLanguageCodes()); - $versionBuilder->createFromAnyContentTypeOf($this->getContentTypeIds()); - - return $this->permissionResolver->lookupLimitations( - 'content', - 'edit', - $contentInfo, - [$versionBuilder->build(), $location], - [Limitation::CONTENTTYPE, Limitation::LANGUAGE] + trigger_deprecation( + 'ibexa/admin-ui', + '4.6', + sprintf('The %s() method is deprecated, will be removed in 5.0.', __METHOD__) ); + + return $this->limitationResolver->getContentUpdateLimitations($location); } /** @@ -299,39 +253,6 @@ private function loadAllUserGroupsIdsOfUser(User $user): array return $allUserGroups; } - - /** - * @return string[] - */ - private function getActiveLanguageCodes(): array - { - $filter = array_filter( - $this->languageService->loadLanguages(), - static function (Language $language) { - return $language->enabled; - } - ); - - return array_column($filter, 'languageCode'); - } - - /** - * @return string[] - */ - private function getContentTypeIds(): array - { - $contentTypeIds = []; - - $contentTypeGroups = $this->contentTypeService->loadContentTypeGroups(); - foreach ($contentTypeGroups as $contentTypeGroup) { - $contentTypes = $this->contentTypeService->loadContentTypes($contentTypeGroup); - foreach ($contentTypes as $contentType) { - $contentTypeIds[] = $contentType->id; - } - } - - return $contentTypeIds; - } } class_alias(PermissionChecker::class, 'EzSystems\EzPlatformAdminUi\Permission\PermissionChecker'); diff --git a/tests/bundle/Templating/Twig/EmbeddedItemEditFormExtensionTest.php b/tests/bundle/Templating/Twig/EmbeddedItemEditFormExtensionTest.php new file mode 100644 index 0000000000..97371e8666 --- /dev/null +++ b/tests/bundle/Templating/Twig/EmbeddedItemEditFormExtensionTest.php @@ -0,0 +1,108 @@ +createFormFactory(), + $this->createRouter() + ), + ]; + } + + /** + * @dataProvider getLegacyTests + * @group legacy + * + * @param string $file + * @param string $message + * @param string $condition + * @param array $templates + * @param string $exception + * @param array $outputs + * @param string $deprecation + */ + public function testLegacyIntegration( + $file, + $message, + $condition, + $templates, + $exception, + $outputs, + $deprecation = '' + ): void { + // disable Twig legacy integration test to avoid producing risky warning + self::markTestSkipped('This package does not contain Twig legacy integration test cases'); + } + + protected function getFixturesDir(): string + { + return __DIR__ . '/_fixtures/render_embedded_item_edit_form/'; + } + + private function createEditForm(): FormInterface + { + $editForm = $this->createMock(FormInterface::class); + $editForm + ->method('createView') + ->willReturn( + $this->createMock(FormView::class) + ); + + return $editForm; + } + + private function createFormFactory(): FormFactory + { + $formFactory = $this->createMock(FormFactory::class); + $formFactory + ->method('contentEdit') + ->with( + new ContentEditData(), + 'embedded_item_edit', + [ + 'action' => self::FORM_ACTION, + 'attr' => [ + 'class' => 'ibexa-embedded-item-edit', + ], + ] + ) + ->willReturn($this->createEditForm()); + + return $formFactory; + } + + private function createRouter(): RouterInterface + { + $router = $this->createMock(RouterInterface::class); + $router + ->method('generate') + ->with('ibexa.content.edit') + ->willReturn(self::FORM_ACTION); + + return $router; + } +} diff --git a/tests/bundle/Templating/Twig/_fixtures/render_embedded_item_edit_form/ibexa_render_embedded_item_edit_form.test b/tests/bundle/Templating/Twig/_fixtures/render_embedded_item_edit_form/ibexa_render_embedded_item_edit_form.test new file mode 100644 index 0000000000..d6679856e7 --- /dev/null +++ b/tests/bundle/Templating/Twig/_fixtures/render_embedded_item_edit_form/ibexa_render_embedded_item_edit_form.test @@ -0,0 +1,9 @@ +--TEST-- +"ibexa_render_embedded_item_edit_form" function +--TEMPLATE-- +{% set form = ibexa_render_embedded_item_edit_form() %} +{% if form is defined %} YES {% else %} NO {% endif %} +--DATA-- +return []; +--EXPECT-- +YES diff --git a/tests/lib/Permission/LimitationResolverTest.php b/tests/lib/Permission/LimitationResolverTest.php new file mode 100644 index 0000000000..084046f6d1 --- /dev/null +++ b/tests/lib/Permission/LimitationResolverTest.php @@ -0,0 +1,283 @@ +permissionResolver = $this->createMock(PermissionResolver::class); + + $this->limitationResolver = new LimitationResolver( + $this->createMock(ContentService::class), + $this->createMock(ContentTypeService::class), + $this->createMock(LanguageService::class), + $this->createMock(LocationService::class), + new LookupLimitationsTransformer(), + $this->permissionResolver + ); + } + + /** + * @dataProvider provideDataForTestGetLanguageLimitations + * + * @param array $expected + * @param iterable<\Ibexa\Contracts\Core\Repository\Values\Content\Language> $languages + */ + public function testGetLanguageLimitations( + array $expected, + ContentInfo $contentInfo, + Location $location, + LookupLimitationResult $lookupLimitationResult, + iterable $languages + ): void { + $this->mockPermissionResolverLookupLimitations( + $contentInfo, + $location, + $lookupLimitationResult + ); + self::assertEquals( + $expected, + $this->limitationResolver->getLanguageLimitations( + 'edit', + $contentInfo, + $languages, + [$location] + ) + ); + } + + /** + * @return iterable, + * \Ibexa\Contracts\Core\Repository\Values\Content\ContentInfo, + * \Ibexa\Contracts\Core\Repository\Values\Content\Location, + * \Ibexa\Contracts\Core\Repository\Values\User\LookupLimitationResult, + * iterable<\Ibexa\Contracts\Core\Repository\Values\Content\Language> + * }> + */ + public function provideDataForTestGetLanguageLimitations(): iterable + { + $english = $this->createLanguage(1, true, 'eng-GB', 'English'); + $german = $this->createLanguage(2, true, 'ger-DE', 'German'); + $french = $this->createLanguage(3, false, 'fra-FR', 'French'); + $contentInfo = $this->createContentInfo(); + $location = $this->createLocation(); + $languages = [ + $english, + $german, + $french, + ]; + + yield 'No access to all languages' => [ + [ + $this->getLanguageAccessData(false, $english), + $this->getLanguageAccessData(false, $german), + $this->getLanguageAccessData(false, $french), + ], + $contentInfo, + $location, + new LookupLimitationResult(false), + $languages, + ]; + + yield 'Access to all enabled languages' => [ + [ + $this->getLanguageAccessData(true, $english), + $this->getLanguageAccessData(true, $german), + $this->getLanguageAccessData(false, $french), + ], + $contentInfo, + $location, + new LookupLimitationResult(true), + $languages, + ]; + + yield 'Limited access to English language by policy limitation' => [ + [ + $this->getLanguageAccessData(true, $english), + $this->getLanguageAccessData(false, $german), + $this->getLanguageAccessData(false, $french), + ], + $contentInfo, + $location, + new LookupLimitationResult( + true, + [], + [ + new LookupPolicyLimitations( + $this->createMock(Policy::class), + [ + $this->createLanguageLimitation(['eng-GB']), + ] + ), + ] + ), + $languages, + ]; + + yield 'Limited access to German language by role limitation' => [ + [ + $this->getLanguageAccessData(false, $english), + $this->getLanguageAccessData(true, $german), + $this->getLanguageAccessData(false, $french), + ], + $contentInfo, + $location, + new LookupLimitationResult( + true, + [ + $this->createLanguageLimitation(['ger-DE']), + ], + ), + $languages, + ]; + + yield 'Limited access to English and German languages by role and policy limitations' => [ + [ + $this->getLanguageAccessData(true, $english), + $this->getLanguageAccessData(true, $german), + $this->getLanguageAccessData(false, $french), + ], + $contentInfo, + $location, + new LookupLimitationResult( + true, + [ + $this->createLanguageLimitation(['eng-GB', 'fra-FR']), + ], + [ + new LookupPolicyLimitations( + $this->createMock(Policy::class), + [ + $this->createLanguageLimitation(['ger-DE', 'fra-FR']), + ] + ), + ] + ), + $languages, + ]; + } + + private function createContentInfo(): ContentInfo + { + return $this->createMock(ContentInfo::class); + } + + private function createLocation(): Location + { + return $this->createMock(Location::class); + } + + private function createLanguage( + int $id, + bool $enabled, + string $languageCode, + string $name + ): Language { + return new Language( + [ + 'id' => $id, + 'enabled' => $enabled, + 'languageCode' => $languageCode, + 'name' => $name, + ] + ); + } + + /** + * @return array{ + * languageCode: string, + * name: string, + * hasAccess: bool, + * } + */ + private function getLanguageAccessData( + bool $hasAccess, + Language $language + ): array { + return [ + 'languageCode' => $language->getLanguageCode(), + 'name' => $language->getName(), + 'hasAccess' => $hasAccess, + ]; + } + + /** + * @param array $limitationValues + */ + private function createLanguageLimitation(array $limitationValues): Limitation\LanguageLimitation + { + return new Limitation\LanguageLimitation( + [ + 'limitationValues' => $limitationValues, + ] + ); + } + + private function mockPermissionResolverLookupLimitations( + ContentInfo $contentInfo, + Location $location, + LookupLimitationResult $lookupLimitationResult + ): void { + $languageCodes = [ + 'eng-GB', + 'ger-DE', + ]; + $targets = [ + $location, + (new VersionBuilder())->translateToAnyLanguageOf($languageCodes)->build(), + ]; + + $this->permissionResolver + ->method('lookupLimitations') + ->with( + 'content', + 'edit', + $contentInfo, + $targets, + [Limitation::LANGUAGE], + ) + ->willReturn($lookupLimitationResult); + } +} diff --git a/tests/lib/Permission/PermissionCheckerTest.php b/tests/lib/Permission/PermissionCheckerTest.php index caba5cec03..57e6328be2 100644 --- a/tests/lib/Permission/PermissionCheckerTest.php +++ b/tests/lib/Permission/PermissionCheckerTest.php @@ -8,11 +8,8 @@ namespace Ibexa\Tests\AdminUi\Permission; +use Ibexa\AdminUi\Permission\LimitationResolverInterface; use Ibexa\AdminUi\Permission\PermissionChecker; -use Ibexa\Contracts\Core\Repository\ContentService; -use Ibexa\Contracts\Core\Repository\ContentTypeService; -use Ibexa\Contracts\Core\Repository\LanguageService; -use Ibexa\Contracts\Core\Repository\LocationService; use Ibexa\Contracts\Core\Repository\PermissionResolver; use Ibexa\Contracts\Core\Repository\UserService; use Ibexa\Contracts\Core\Repository\Values\Content; @@ -27,23 +24,14 @@ class PermissionCheckerTest extends TestCase { private const USER_ID = 14; - /** @var \Ibexa\Contracts\Core\Repository\PermissionResolver */ + /** @var \Ibexa\Contracts\Core\Repository\PermissionResolver&\PHPUnit\Framework\MockObject\MockObject */ private $permissionResolver; - /** @var \Ibexa\Contracts\Core\Repository\UserService */ + /** @var \Ibexa\Contracts\Core\Repository\UserService&\PHPUnit\Framework\MockObject\MockObject */ private $userService; - /** @var \Ibexa\Contracts\Core\Repository\LocationService */ - private $locationService; - - /** @var \Ibexa\Contracts\Core\Repository\ContentService */ - private $contentService; - - /** @var \Ibexa\Contracts\Core\Repository\ContentTypeService */ - private $contentTypeService; - - /** @var \Ibexa\Contracts\Core\Repository\LanguageService */ - private $languageService; + /** @var \Ibexa\AdminUi\Permission\LimitationResolverInterface&\PHPUnit\Framework\MockObject\MockObject */ + private LimitationResolverInterface $permissionLimitationResolver; /** @var \Ibexa\AdminUi\Permission\PermissionChecker */ private $permissionChecker; @@ -55,19 +43,13 @@ public function setUp(): void ->method('getCurrentUserReference') ->willReturn($this->generateUser(self::USER_ID)); + $this->permissionLimitationResolver = $this->createMock(LimitationResolverInterface::class); $this->userService = $this->createMock(UserService::class); - $this->locationService = $this->createMock(LocationService::class); - $this->contentService = $this->createMock(ContentService::class); - $this->contentTypeService = $this->createMock(ContentTypeService::class); - $this->languageService = $this->createMock(LanguageService::class); $this->permissionChecker = new PermissionChecker( $this->permissionResolver, + $this->permissionLimitationResolver, $this->userService, - $this->locationService, - $this->contentService, - $this->contentTypeService, - $this->languageService ); }