diff --git a/bundles/core/src/main/java/com/adobe/cq/commerce/core/components/internal/models/v1/breadcrumb/BreadcrumbImpl.java b/bundles/core/src/main/java/com/adobe/cq/commerce/core/components/internal/models/v1/breadcrumb/BreadcrumbImpl.java index 57c60c8636..2f2c407a7b 100644 --- a/bundles/core/src/main/java/com/adobe/cq/commerce/core/components/internal/models/v1/breadcrumb/BreadcrumbImpl.java +++ b/bundles/core/src/main/java/com/adobe/cq/commerce/core/components/internal/models/v1/breadcrumb/BreadcrumbImpl.java @@ -42,6 +42,7 @@ import com.adobe.cq.commerce.core.components.internal.datalayer.DataLayerComponent; import com.adobe.cq.commerce.core.components.internal.services.site.SiteStructureImpl; import com.adobe.cq.commerce.core.components.internal.services.urlformats.UrlFormatBase; +import com.adobe.cq.commerce.core.components.internal.utils.VersionHistoryUtils; import com.adobe.cq.commerce.core.components.models.breadcrumb.Breadcrumb; import com.adobe.cq.commerce.core.components.models.common.SiteStructure; import com.adobe.cq.commerce.core.components.models.navigation.Navigation; @@ -106,17 +107,21 @@ void initModel() { @Override public Collection getItems() { - // Useful for the template editor - if (!currentPage.getPath().startsWith("/content")) { + BreadcrumbContext context = getBreadcrumbContext(); + if (context == null) { return Collections.emptyList(); } if (items == null) { items = new ArrayList<>(); if (magentoGraphqlClient != null) { - Collection pageItems = breadcrumb.getItems(); - for (NavigationItem item : pageItems) { - if (!populateItems(item)) { + for (NavigationItem item : getPageItems(context)) { + Page page = context.versionPreview ? item.getPage() : resolveSourcePage(item.getPage()); + if (page == null) { + continue; + } + + if (!populateItems(item, page, context)) { break; } } @@ -125,6 +130,56 @@ public Collection getItems() { return Collections.unmodifiableList(items); } + private BreadcrumbContext getBreadcrumbContext() { + Resource currentPageResource = currentPage != null ? currentPage.adaptTo(Resource.class) : null; + boolean versionPreview = VersionHistoryUtils.isVersionPreviewResource(currentPageResource); + Page breadcrumbPage = versionPreview ? resolveSourcePage(currentPage) : currentPage; + + // Useful for the template editor + if (breadcrumbPage == null || !breadcrumbPage.getPath().startsWith("/content")) { + return null; + } + + SiteStructure effectiveSiteStructure = versionPreview ? breadcrumbPage.adaptTo(SiteStructure.class) : siteStructure; + if (effectiveSiteStructure == null) { + effectiveSiteStructure = siteStructure; + } + + return new BreadcrumbContext(currentPageResource, breadcrumbPage, effectiveSiteStructure, versionPreview); + } + + private Collection getPageItems(BreadcrumbContext context) { + return context.versionPreview ? getVersionPreviewItems(context) : breadcrumb.getItems(); + } + + private Collection getVersionPreviewItems(BreadcrumbContext context) { + Page sourceCurrentPage = context.breadcrumbPage; + int startLevel = properties.get(com.adobe.cq.wcm.core.components.models.Breadcrumb.PN_START_LEVEL, + currentStyle.get(com.adobe.cq.wcm.core.components.models.Breadcrumb.PN_START_LEVEL, 2)); + Page firstIncludedSourcePage = sourceCurrentPage.getAbsoluteParent(startLevel); + if (firstIncludedSourcePage == null) { + return Collections.emptyList(); + } + + List pages = new ArrayList<>(); + for (Page page = sourceCurrentPage; page != null; page = page.getParent()) { + pages.add(page); + if (StringUtils.equals(page.getPath(), firstIncludedSourcePage.getPath())) { + break; + } + } + + Collections.reverse(pages); + return pages.stream() + .map(page -> newNavigationItem( + page, + getPageTitle(page), + toContextualUrl(context, page.getPath() + ".html"), + StringUtils.equals(page.getPath(), sourceCurrentPage.getPath()), + currentPage.getContentResource())) + .collect(Collectors.toList()); + } + /** * Populates the breadcrumb items with the given item. If the item * a) is a content page it is kept as is @@ -134,21 +189,13 @@ public Collection getItems() { * @param item * @return true if more original items should be considered for the breadcrumb, otherwise false */ - private boolean populateItems(NavigationItem item) { - Page page = item.getPage(); + private boolean populateItems(NavigationItem item, Page page, BreadcrumbContext context) { Resource contentResource; - // We build the breadcrumb based on the production version of the page structure - if (page != null && LaunchUtils.isLaunchBasedPath(page.getPath())) { - PageManager pageManager = page.getPageManager(); - contentResource = LaunchUtils.getTargetResource(page.getContentResource(), null); - page = pageManager.getContainingPage(contentResource); - } - contentResource = page != null ? page.getContentResource() : null; // If we encounter the catalog page and it's configured to show the main categories, we skip that page - if (siteStructure.isCatalogPage(page)) { + if (context.siteStructure.isCatalogPage(page)) { if (contentResource.getValueMap().get(Navigation.PN_SHOW_MAIN_CATEGORIES, Boolean.TRUE)) { return true; } @@ -160,7 +207,7 @@ private boolean populateItems(NavigationItem item) { List categoriesBreadcrumbs = null; ProductInterface product = null; - if (siteStructure.isProductPage(page)) { + if (context.siteStructure.isProductPage(page)) { categoriesBreadcrumbs = fetchProductBreadcrumbs(); product = retriever.fetchProduct(); isProductPage = true; @@ -168,12 +215,16 @@ private boolean populateItems(NavigationItem item) { if (product == null) { return false; } - } else if (siteStructure.isCategoryPage(page)) { + } else if (context.siteStructure.isCategoryPage(page)) { categoriesBreadcrumbs = fetchCategoryBreadcrumbs(); isCategoryPage = true; } else { // we reached a content page - items.add(item); + String url = toContextualUrl(context, item.getURL()); + String title = context.versionPreview ? getPageTitle(page) : item.getTitle(); + items.add(url.equals(item.getURL()) && StringUtils.equals(title, item.getTitle()) ? item + : newNavigationItem(title, url, + item.isActive())); return true; } @@ -181,7 +232,7 @@ private boolean populateItems(NavigationItem item) { return false; } - SiteStructure.Entry siteStructureEntry = siteStructure.getEntry(page); + SiteStructure.Entry siteStructureEntry = context.siteStructure.getEntry(page); // A product can be in multiple categories so we select the "primary" category categoriesBreadcrumbs.sort(Comparator.comparing(CategoryInterface::getUrlPath).reversed()); @@ -210,7 +261,7 @@ && shouldIncludeInBreadcrumb(categoryBreadcrumb.getUrlPath(), siteStructureEntry // We finally add the product if it's a product page if (isProductPage) { ProductUrlFormat.Params params = new ProductUrlFormat.Params(product); - String url = urlProvider.toProductUrl(request, currentPage, params); + String url = toContextualUrl(context, urlProvider.toProductUrl(request, currentPage, params)); NavigationItemImpl productItem = newNavigationItem(product.getName(), url, true); items.add(productItem); } @@ -218,6 +269,33 @@ && shouldIncludeInBreadcrumb(categoryBreadcrumb.getUrlPath(), siteStructureEntry return false; } + private Page resolveSourcePage(Page page) { + if (page == null) { + return null; + } + + if (LaunchUtils.isLaunchBasedPath(page.getPath())) { + PageManager pageManager = page.getPageManager(); + Resource launchSourceResource = LaunchUtils.getTargetResource(page.getContentResource(), null); + Page launchSourcePage = pageManager != null ? pageManager.getContainingPage(launchSourceResource) : null; + if (launchSourcePage != null) { + return launchSourcePage; + } + } + + Resource pageResource = page.adaptTo(Resource.class); + if (VersionHistoryUtils.isVersionPreviewResource(pageResource)) { + PageManager pageManager = page.getPageManager(); + Resource sourceResource = VersionHistoryUtils.resolveSourceResource(pageResource); + Page sourcePage = pageManager != null ? pageManager.getContainingPage(sourceResource) : null; + if (sourcePage != null) { + return sourcePage; + } + } + + return page; + } + private boolean shouldIncludeInBreadcrumb(String breadcrumbUrlPath, Page catalogPage) { ValueMap properties = catalogPage != null ? catalogPage.getProperties() : ValueMap.EMPTY; boolean showMainCategories = properties.get(Navigation.PN_SHOW_MAIN_CATEGORIES, Boolean.TRUE); @@ -255,7 +333,9 @@ private void addCategoryItem(ID uid, String urlKey, String urlPath, String name, params.setUid(uid.toString()); params.setUrlKey(urlKey); params.setUrlPath(urlPath); - String url = urlProvider.toCategoryUrl(request, currentPage, params); + String url = VersionHistoryUtils.toVersionPreviewUrl( + currentPage != null ? currentPage.adaptTo(Resource.class) : null, + urlProvider.toCategoryUrl(request, currentPage, params)); // if there is no category page, the url will contain the placeholder {{page}} if (!url.contains(PAGE_PLACEHOLDER)) { NavigationItemImpl categoryItem = newNavigationItem(name, url, isActive); @@ -279,6 +359,34 @@ private NavigationItemImpl newNavigationItem(String name, String url, boolean is return new NavigationItemImpl(name, url, isActive, this.getId(), currentPage.getContentResource()); } + private NavigationItemImpl newNavigationItem(Page page, String name, String url, boolean isActive, Resource resource) { + return new NavigationItemImpl(page, name, url, isActive, this.getId(), resource); + } + + private String toContextualUrl(BreadcrumbContext context, String url) { + return VersionHistoryUtils.toVersionPreviewUrl(context.currentPageResource, url); + } + + private String getPageTitle(Page page) { + return StringUtils.defaultIfBlank( + page.getNavigationTitle(), + StringUtils.defaultIfBlank(page.getPageTitle(), StringUtils.defaultIfBlank(page.getTitle(), page.getName()))); + } + + private static final class BreadcrumbContext { + private final Resource currentPageResource; + private final Page breadcrumbPage; + private final SiteStructure siteStructure; + private final boolean versionPreview; + + private BreadcrumbContext(Resource currentPageResource, Page breadcrumbPage, SiteStructure siteStructure, boolean versionPreview) { + this.currentPageResource = currentPageResource; + this.breadcrumbPage = breadcrumbPage; + this.siteStructure = siteStructure; + this.versionPreview = versionPreview; + } + } + @Override public Comparator getCategoryInterfaceComparator() { return Comparator diff --git a/bundles/core/src/main/java/com/adobe/cq/commerce/core/components/internal/models/v1/breadcrumb/NavigationItemImpl.java b/bundles/core/src/main/java/com/adobe/cq/commerce/core/components/internal/models/v1/breadcrumb/NavigationItemImpl.java index 6ffbe781ce..17c70b46a2 100644 --- a/bundles/core/src/main/java/com/adobe/cq/commerce/core/components/internal/models/v1/breadcrumb/NavigationItemImpl.java +++ b/bundles/core/src/main/java/com/adobe/cq/commerce/core/components/internal/models/v1/breadcrumb/NavigationItemImpl.java @@ -19,15 +19,22 @@ import com.adobe.cq.commerce.core.components.internal.datalayer.DataLayerListItem; import com.adobe.cq.wcm.core.components.models.NavigationItem; +import com.day.cq.wcm.api.Page; public class NavigationItemImpl extends DataLayerListItem implements NavigationItem { protected String title; protected String url; protected boolean isActive; + protected Page page; public NavigationItemImpl(String title, String url, boolean isActive, String parentId, Resource resource) { + this(null, title, url, isActive, parentId, resource); + } + + public NavigationItemImpl(Page page, String title, String url, boolean isActive, String parentId, Resource resource) { super(parentId, resource); + this.page = page; this.title = title; this.url = url; this.isActive = isActive; @@ -48,6 +55,11 @@ public boolean isActive() { return isActive; } + @Override + public Page getPage() { + return page; + } + // DataLayer methods @Override diff --git a/bundles/core/src/main/java/com/adobe/cq/commerce/core/components/internal/utils/VersionHistoryUtils.java b/bundles/core/src/main/java/com/adobe/cq/commerce/core/components/internal/utils/VersionHistoryUtils.java index afb6921aac..ed948bfb11 100644 --- a/bundles/core/src/main/java/com/adobe/cq/commerce/core/components/internal/utils/VersionHistoryUtils.java +++ b/bundles/core/src/main/java/com/adobe/cq/commerce/core/components/internal/utils/VersionHistoryUtils.java @@ -57,7 +57,38 @@ public static Resource resolveSourceResource(Resource resource) { return resource; } + /** + * Keeps generated URLs inside the current /tmp/versionhistory preview tree. + */ + public static String toVersionPreviewUrl(Resource versionPreviewResource, String url) { + if (!isVersionPreviewResource(versionPreviewResource) || StringUtils.isBlank(url) || !StringUtils.startsWith(url, "/content/")) { + return url; + } + + String sourceRelativePath = getSourceRelativePath(versionPreviewResource.getPath()); + if (StringUtils.isBlank(sourceRelativePath)) { + return url; + } + + String previewPath = versionPreviewResource.getPath(); + if (!StringUtils.endsWith(previewPath, sourceRelativePath)) { + return url; + } + + String previewRoot = StringUtils.substringBeforeLast(previewPath, sourceRelativePath); + return previewRoot + StringUtils.removeStart(url, "/content/"); + } + private static String getSourcePagePath(String path) { + String relativePath = getSourceRelativePath(path); + if (StringUtils.isBlank(relativePath)) { + return null; + } + + return "/content/" + StringUtils.removeEnd(relativePath, "/"); + } + + private static String getSourceRelativePath(String path) { String suffix = StringUtils.substringAfter(path, VERSION_HISTORY_ROOT); if (StringUtils.isBlank(suffix)) { return null; @@ -68,7 +99,6 @@ private static String getSourcePagePath(String path) { return null; } - String relativePath = suffix.substring(secondSlash + 1); - return "/content/" + StringUtils.removeEnd(relativePath, "/"); + return StringUtils.removeEnd(suffix.substring(secondSlash + 1), "/"); } } diff --git a/bundles/core/src/test/java/com/adobe/cq/commerce/core/components/internal/models/v1/breadcrumb/BreadcrumbImplTest.java b/bundles/core/src/test/java/com/adobe/cq/commerce/core/components/internal/models/v1/breadcrumb/BreadcrumbImplTest.java index 0989baecba..38279daa99 100644 --- a/bundles/core/src/test/java/com/adobe/cq/commerce/core/components/internal/models/v1/breadcrumb/BreadcrumbImplTest.java +++ b/bundles/core/src/test/java/com/adobe/cq/commerce/core/components/internal/models/v1/breadcrumb/BreadcrumbImplTest.java @@ -76,6 +76,7 @@ public class BreadcrumbImplTest { "my-store", "enableUIDSupport", "true")); private static final ComponentsConfiguration MOCK_CONFIGURATION_OBJECT = new ComponentsConfiguration(MOCK_CONFIGURATION); + private static final String VERSION_PREVIEW_ROOT = "/tmp/versionhistory/hash/version"; @Rule public final AemContext context = buildAemContext("/context/jcr-content-breadcrumb.json") @@ -97,6 +98,7 @@ public class BreadcrumbImplTest { @Before public void setUp() throws Exception { + context.load().json("/context/jcr-content-breadcrumb.json", VERSION_PREVIEW_ROOT); httpClient = mock(CloseableHttpClient.class); context.registerService(HttpClientBuilderFactory.class, new MockHttpClientBuilderFactory(httpClient)); @@ -311,6 +313,71 @@ public void testCategoryPage() throws Exception { assertThat(topsCategory.isActive()).isTrue(); } + @Test + public void testCategoryPageOnVersionPreview() throws Exception { + Utils.setupHttpResponse("graphql/magento-graphql-category-breadcrumb-result.json", httpClient, HttpStatus.SC_OK, + "{categoryList(filters:{url_path"); + + String versionPreviewPage = VERSION_PREVIEW_ROOT + "/venia/us/en/products/category-page"; + prepareModel(versionPreviewPage); + + MockRequestPathInfo requestPathInfo = (MockRequestPathInfo) context.request().getRequestPathInfo(); + requestPathInfo.setSuffix("/men.html"); + + breadcrumbModel = context.request().adaptTo(BreadcrumbImpl.class); + List items = (List) breadcrumbModel.getItems(); + assertThat(items.stream().map(NavigationItem::getTitle)).containsExactly("en", "Men", "Tops"); + + NavigationItem homeItem = items.get(0); + assertThat(homeItem.getURL()).startsWith(VERSION_PREVIEW_ROOT + "/venia"); + + NavigationItem menCategory = items.get(1); + assertThat(menCategory.getURL()).isEqualTo(versionPreviewPage + ".html/men.html"); + assertThat(menCategory.isActive()).isFalse(); + + NavigationItem topsCategory = items.get(2); + assertThat(topsCategory.getURL()).isEqualTo(versionPreviewPage + ".html/men/tops-men.html"); + assertThat(topsCategory.isActive()).isTrue(); + } + + @Test + public void testVersionPreviewSkipsSyntheticAncestorItems() throws Exception { + String versionPreviewPage = VERSION_PREVIEW_ROOT + "/venia/us/en/another-page"; + String syntheticPreviewPage = VERSION_PREVIEW_ROOT + "/venia/us/en/20e4b245-cbeb-4769-b6f1-5018354508b2"; + prepareModel(versionPreviewPage); + context.resourceResolver().getResource("/content/venia/us/en/jcr:content") + .adaptTo(ModifiableValueMap.class) + .put(JcrConstants.JCR_TITLE, "Venia Demo Store - Home"); + + breadcrumbModel = context.request().adaptTo(BreadcrumbImpl.class); + + com.adobe.cq.wcm.core.components.models.Breadcrumb wrappedBreadcrumb = mock( + com.adobe.cq.wcm.core.components.models.Breadcrumb.class); + List wrappedItems = Arrays.asList( + mockNavigationItem( + "20e4b245-cbeb-4769-b6f1-5018354508b2", + syntheticPreviewPage, + syntheticPreviewPage + ".html", + false), + mockNavigationItem( + "en", + VERSION_PREVIEW_ROOT + "/venia/us/en", + "/content/venia/us/en.html", + false), + mockNavigationItem( + "another-page", + versionPreviewPage, + "/content/venia/us/en/another-page.html", + true)); + when(wrappedBreadcrumb.getItems()).thenReturn(wrappedItems); + Whitebox.setInternalState(breadcrumbModel, "breadcrumb", wrappedBreadcrumb); + + List items = (List) breadcrumbModel.getItems(); + assertThat(items.stream().map(NavigationItem::getTitle)).containsExactly("Venia Demo Store - Home", "another-page"); + assertThat(items.get(0).getURL()).isEqualTo(VERSION_PREVIEW_ROOT + "/venia/us/en.html"); + assertThat(items.get(1).getURL()).isEqualTo(versionPreviewPage + ".html"); + } + @Test public void testCategorySpecificPage() throws Exception { Utils.setupHttpResponse("graphql/magento-graphql-category-breadcrumb-result.json", httpClient, HttpStatus.SC_OK, @@ -529,4 +596,19 @@ public void testBreadcrumbContainsOnlyDescendantCategoriesOfSpecificCatalogPage( assertThat(product.getURL()).isEqualTo("/content/venia/us/en/products/product-page.html/tiberius-gym-tank.html"); assertThat(product.isActive()).isTrue(); } + + private NavigationItem mockNavigationItem(String title, String pagePath, String url, boolean active) { + NavigationItem item = mock(NavigationItem.class); + Page page = context.pageManager().getPage(pagePath); + if (page == null) { + context.create().page(pagePath); + page = context.pageManager().getPage(pagePath); + } + + when(item.getPage()).thenReturn(page); + when(item.getTitle()).thenReturn(title); + when(item.getURL()).thenReturn(url); + when(item.isActive()).thenReturn(active); + return item; + } } diff --git a/bundles/core/src/test/java/com/adobe/cq/commerce/core/components/internal/utils/VersionHistoryUtilsTest.java b/bundles/core/src/test/java/com/adobe/cq/commerce/core/components/internal/utils/VersionHistoryUtilsTest.java index 890387ad0b..7d037d38a1 100644 --- a/bundles/core/src/test/java/com/adobe/cq/commerce/core/components/internal/utils/VersionHistoryUtilsTest.java +++ b/bundles/core/src/test/java/com/adobe/cq/commerce/core/components/internal/utils/VersionHistoryUtilsTest.java @@ -94,6 +94,13 @@ public void testResolveUnknownPathWithoutCandidateReturnsSameResource() { Assert.assertEquals(VERSION_HISTORY_ROOT + "/hash/version/unknown", resolved.getPath()); } + @Test + public void testToVersionPreviewUrlMapsContentUrlToPreviewUrl() { + Resource resource = context.create().resource(VERSION_HISTORY_ROOT + "/hash/version/site/page"); + String previewUrl = VersionHistoryUtils.toVersionPreviewUrl(resource, "/content/site/other-page.html"); + Assert.assertEquals(VERSION_HISTORY_ROOT + "/hash/version/site/other-page.html", previewUrl); + } + @Test public void testIsVersionHistoryResource() { Resource versionResource = context.create().resource(VERSION_HISTORY_ROOT + "/hash/version/site/page");