From 28c8a78f3424ad80f157fe2484ba692a591d4ad2 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 12 Sep 2023 23:24:05 -0400 Subject: [PATCH] Templates, compound blocks & muliti-section blocks support (#68) * feat: support for compound blocks, multi-section blocks and templates * Fix: add padding when top level element doesn't have arrow * fix: lint * chore: README update * chore: Update README.md * fix: broken progress bar * fix: don't modify page metadata keys * fix: feedback updates * chore: Update README.md --- README.md | 46 +- src/components/block-list/block-list.js | 268 ++++++++-- .../block-renderer/block-renderer.js | 31 +- src/components/sidenav/sidenav-item.js | 10 +- src/plugins/blocks/blocks.js | 468 ++++++++++-------- src/plugins/blocks/utils.js | 238 ++++++++- src/utils/dom.js | 24 +- .../block-renderer/block-renderer.test.js | 136 +++-- test/fixtures/blocks.js | 29 ++ test/fixtures/libraries.js | 30 ++ test/fixtures/pages.js | 29 ++ test/fixtures/stubs/compound-block.js | 99 ++++ test/fixtures/stubs/pages.js | 2 +- test/fixtures/stubs/tabs.js | 138 ++++++ test/fixtures/stubs/template.js | 92 ++++ test/franklin-library.test.js | 62 ++- test/plugins/blocks/blocks.test.js | 317 +++++++++++- 17 files changed, 1644 insertions(+), 375 deletions(-) create mode 100644 test/fixtures/stubs/compound-block.js create mode 100644 test/fixtures/stubs/tabs.js create mode 100644 test/fixtures/stubs/template.js diff --git a/README.md b/README.md index 04dfd3b..b11e386 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,27 @@ To generate content for the blocks plugin, you need to prepare a separate Word d ![Library.xlsx](https://github.com/adobe/franklin-sidekick-library/assets/3231084/5f645ab8-cc30-4cd6-932b-94024d01713b) -### (Optional) Authoring block names and descriptions. +## Library Metadata +The blocks plugins supports a special type of block called `library metadata` which provides a way for developers to tell the blocks plugin some information about the block or how it should be rendered. -By default the block name (with variation) will be used to render the item in the blocks plugin. For example, if the name of the block is `columns (center, background)` than that name will be used as the label when it’s rendered in the blocks plugin. This can be customized by creating a library metadata section within the same section as the block. Library metadata can also be used to author a description of the block as well as adding `searchTags` to include an alias for the block when using the search feature. +### Supported library metadata options +| Key Name | Value | Description | Required | +|-----------------------|------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------| +| name | Name of the block | Allows you to set a custom name for the block | false | +| description | A description of the block | Allows you to set a custom description for a block | false | +| type | The type of the block | This tells the blocks plugin how to group the content that makes up your block. Possible options are `template` or `section` (details below) | false | +| include next sections | How many sections to include in the block item | Use if your block requires content from subsequence sections in order to render. Should be a number value that indicates how much subsequent sections to include. | false | +| searchtags | A comma seperated list of search terms | Allows you to define other terms that could be used when searching for this block in the blocks plugin | false | + +### Default Library metadata vs Library metadata + +There are two types of `library metadata`. Library metadata that lives within a section containing the block, or `default library metadata` that applies to the document as a whole and lives in a section on it's own (only child in a section). + +Let's take an example of a hero block that has 5 variants. Suppose you want to add the same description for each variation of the block, rather than duplicating the `library metadata` with the description into each section containing the variations. You could instead use `default library metadata` to apply the same description to every variation of the block. If you decide that one variation actually needs a slightly different description you could add `library metadata` to the section containing the variation and it would override the `default library metadata` description when it's rendered within the blocks plugin. + +### Authoring block names and descriptions using Library Metadata + +By default the block name (with variation) will be used to render the item in the blocks plugin. For example, if the name of the block is `columns (center, background)` than that name will be used as the label when it’s rendered in the blocks plugin. This can be customized by creating a `library metadata` section within the same section as the block. Library metadata can also be used to author a description of the block as well as adding `searchTags` to include an alias for the block when using the search feature. Example block with custom name and description @@ -61,7 +79,29 @@ Example block with custom name and description ### Autoblocks and Default Content -The blocks plugin is capable of rendering [default content](https://www.hlx.live/developer/markup-sections-blocks#default-content) and [autoblocks](https://www.hlx.live/developer/markup-sections-blocks#auto-blocking). In order to achieve this, it is necessary to place your `default content` or `autoblock` within a dedicated section, which should include a library metadata table defining a name property, as previously described. If no name is specified in the library metadata, the item will be labeled as "Unnamed Item." +The blocks plugin is capable of rendering [default content](https://www.hlx.live/developer/markup-sections-blocks#default-content) and [autoblocks](https://www.hlx.live/developer/markup-sections-blocks#auto-blocking). In order to achieve this, it is necessary to place your `default content` or `autoblock` within a dedicated section, which should include a `library metadata` table defining a `name` property, as previously described. If no name is specified in the library metadata, the item will be labeled as "Unnamed Item." + +### Multi-section Blocks + +Multi-section blocks are a way to group multiple sections into a single item in the blocks plugin. Some block implementations require multiple sections of content. A common example of this is a tabs block where the subsequent sections after the block is declared contain the content for each tab. + +In order to tell the block plugin to include an `n` number of subsequent sections you can use the `include next sections` property in `library metadata`. + +![Screenshot 2023-09-07 at 2 42 13 PM](https://github.com/adobe/franklin-sidekick-library/assets/3231084/09353409-9036-4e18-8f52-597897b4e1d2) + +In the example above, the block plugin will group this section and the 3 sections after into a single item. + +### Templates + +Templates are a way to group an entire document into a single element in the sidekick library. To mark a document as a template set `type` to `template` in `default library metadata`. + +> Important, the `library metadata` needs to be in it's own section and be the only child to be considered `default library metadata`. + +Supporting `metadata` is also desirable for templates. To add a metadata table to the template you can use a `Page metadata` block. + +![266064147-12883ee0-147b-4171-b89a-c313e33eef24](https://github.com/adobe/franklin-sidekick-library/assets/3231084/d4b6f9af-0829-4c73-815f-0cac036ce942) + +When the template is copied a `metadata` with the values will be added along with the content to the clipboard. ## Sidekick plugin setup diff --git a/src/components/block-list/block-list.js b/src/components/block-list/block-list.js index fec9018..c7990ec 100644 --- a/src/components/block-list/block-list.js +++ b/src/components/block-list/block-list.js @@ -18,12 +18,14 @@ import { getBlockName, getDefaultLibraryMetadata, getLibraryMetadata, + getPageMetadata, } from '../../plugins/blocks/utils.js'; import { createSideNavItem, createTag } from '../../utils/dom.js'; export class BlockList extends LitElement { static properties = { mutationObserver: { state: false }, + selectedItem: { state: false }, type: { state: true }, }; @@ -181,46 +183,41 @@ export class BlockList extends LitElement { throw new Error(`An error occurred fetching ${blockData.name}`); } - // Add block parent sidenav item - const blockParentItem = createSideNavItem( - blockData.name, - 'sp-icon-file-template', - true, - true, - 'sp-icon-preview', - ); - blockParentItems.push(blockParentItem); - - blockParentItem.addEventListener('OnAction', e => this.onPreview(e, blockURL)); - // Get the body container of the block variants, clone it so we don't mutate the original const { body } = blockDocument.cloneNode(true); // Check for default library metadata const defaultLibraryMetadata = getDefaultLibraryMetadata(body) ?? {}; - // Query all variations of the block in the container - const pageBlocks = body.querySelectorAll(':scope > div'); + // Get the block type + const blockType = defaultLibraryMetadata.type ?? undefined; - pageBlocks.forEach((blockWrapper, index) => { - // Check if the variation has library metadata - const sectionLibraryMetadata = getLibraryMetadata(blockWrapper) ?? {}; - const blockElement = blockWrapper.querySelector('div[class]'); - let itemName = sectionLibraryMetadata.name ?? getBlockName(blockElement); - const blockNameWithVariant = getBlockName(blockElement, true); - const searchTags = sectionLibraryMetadata.searchtags - ?? sectionLibraryMetadata['search-tags'] - ?? defaultLibraryMetadata.searchtags - ?? defaultLibraryMetadata['search-tags'] - ?? ''; + // Check for page metadata + const pageMetadata = getPageMetadata(body); + + // Parent item for templates + let templatesParentItem; + + // Is this a template? + if (blockType && blockType.toLowerCase() === 'template') { + // If templates parent sidenav item doesn't exist, create it + if (!templatesParentItem) { + templatesParentItem = createSideNavItem( + 'Templates', + 'sp-icon-file-code', + true, + false, + ); - // If the item doesn't have an authored or default - // name (default content), set to 'Unnamed Item' - if (!itemName || itemName === 'section-metadata') { - itemName = 'Unnamed Item'; + blockParentItems.push(templatesParentItem); } - const blockVariantItem = createSideNavItem( + // For templates we pull the template name from default library metadata + // or the name given to the document in the library sheet. + const itemName = defaultLibraryMetadata.name ?? blockData.name; + const searchTags = defaultLibraryMetadata.searchtags ?? defaultLibraryMetadata['search-tags'] ?? ''; + + const pageItem = createSideNavItem( itemName, 'sp-icon-file-code', false, @@ -230,38 +227,196 @@ export class BlockList extends LitElement { // Add search tags to the sidenav item if (searchTags) { - blockVariantItem.setAttribute('data-search-tags', searchTags); + pageItem.setAttribute('data-search-tags', searchTags); } - blockVariantItem.classList.add('descendant'); - blockVariantItem.setAttribute('data-index', index); - blockVariantItem.addEventListener('OnAction', (e) => { - e.preventDefault(); - e.stopPropagation(); - this.dispatchEvent(new CustomEvent('CopyBlock', { detail: { blockWrapper, blockNameWithVariant, blockURL } })); - }); - - // Add child variant to parent - blockParentItem.append(blockVariantItem); - - // Construct a load payload + // Construct an event payload const eventPayload = { detail: { - blockWrapper, blockData, sectionLibraryMetadata, defaultLibraryMetadata, index, + blockWrapper: body, + blockData, + blockURL, + defaultLibraryMetadata, + pageMetadata, }, }; - // On item click - blockVariantItem.addEventListener('click', async () => { - this.dispatchEvent(new CustomEvent('LoadBlock', eventPayload)); + // Handle sidenav item actions (copy) + pageItem.addEventListener('OnAction', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.dispatchEvent(new CustomEvent('CopyBlock', eventPayload)); + }); + + // Add the template to the templates sidenav item + templatesParentItem.append(pageItem); + + // On item click.. Load the template + pageItem.addEventListener('click', async () => { + this.dispatchEvent(new CustomEvent('LoadTemplate', eventPayload)); }); - // If the block path and index match the URL params, load the block - if (dlPath === path && dlIndex === index.toString()) { - blockParentItem.setAttribute('expanded', true); - this.dispatchEvent(new CustomEvent('LoadBlock', eventPayload)); + // If the template path matches the URL params, load the block + if (dlPath === path) { + templatesParentItem.setAttribute('expanded', true); + this.selectedItem = pageItem; + this.dispatchEvent(new CustomEvent('LoadTemplate', eventPayload)); } - }); + } else { + // This is just a block.. single, compound or multi-section + // Add block parent sidenav item + const blockParentItem = createSideNavItem( + blockData.name, + 'sp-icon-file-template', + true, + true, + 'sp-icon-preview', + ); + + // Add to the block parent items array + blockParentItems.push(blockParentItem); + + // Listen for preview events + blockParentItem.addEventListener('OnAction', e => this.onPreview(e, blockURL)); + + // Query all variations of the block in the container + const pageBlocks = body.querySelectorAll(':scope > div'); + + let skipNext = 0; + + pageBlocks.forEach((blockWrapper, index) => { + // If the previous block had an includeNextSections attribute (multi-section block) + // we need may need to skip the next n number of siblings since + // they are part of the multi-section block + if (skipNext > 0) { + skipNext -= 1; + return; + } + + // Check if the variation has library metadata + const sectionLibraryMetadata = getLibraryMetadata(blockWrapper) ?? {}; + const blockElement = blockWrapper.querySelector('div[class]'); + let itemName = sectionLibraryMetadata.name ?? getBlockName(blockElement); + const blockNameWithVariant = getBlockName(blockElement, true); + const searchTags = sectionLibraryMetadata.searchtags + ?? sectionLibraryMetadata['search-tags'] + ?? defaultLibraryMetadata.searchtags + ?? defaultLibraryMetadata['search-tags'] + ?? ''; + + // If the item doesn't have an authored or default + // name (default content), set to 'Unnamed Item' + if (!itemName || itemName === 'section-metadata') { + itemName = 'Unnamed Item'; + } + + // Create the sidenav item for the variant + const blockVariantItem = createSideNavItem( + itemName, + 'sp-icon-file-code', + false, + true, + 'sp-icon-copy', + ); + + // Add search tags to the sidenav item + if (searchTags) { + blockVariantItem.setAttribute('data-search-tags', searchTags); + } + + // Check if the section has an includeNextSections attribute + // If it does is this a multi-section block + if (sectionLibraryMetadata.includeNextSections) { + const includeNextSections = Number(sectionLibraryMetadata.includeNextSections); + + // Make sure the includeNext value is a number, if not ignore + if (!Number.isNaN(includeNextSections)) { + // We need to take all the sections that make up this block and + // append them to a new body element + const bodyElement = document.createElement('body'); + + let i = 0; + // Append the next x number of siblings to the blockWrapper + while (i < includeNextSections) { + // Pull out the next sibling and append it to the body element + const nextSibling = blockWrapper.nextElementSibling; + bodyElement.append(nextSibling); + i += 1; + } + + // Prepend the original blockWrapper to the body element + bodyElement.prepend(blockWrapper); + + // Reassign the blockWrapper to the new body element + blockWrapper = bodyElement; + + // Tell the next iteration to skip the next x number of siblings + skipNext = includeNextSections; + + // Remember this is a multi-section block + defaultLibraryMetadata.multiSectionBlock = true; + } + } else if (blockWrapper.querySelectorAll('div[class]:not(.section-metadata)').length > 1) { + // We need to take all the blocks in the section to make up the compound block and + // append them to a new body element + const compoundBodyElement = document.createElement('body'); + + // Take the parent of this block and append to the compound body element + compoundBodyElement.append(blockWrapper); + + // Reassign the blockWrapper to the new body element + blockWrapper = compoundBodyElement; + + // Remember this is a compound block + defaultLibraryMetadata.compoundBlock = true; + } + + // Construct an event payload + const eventPayload = { + detail: { + blockWrapper, + blockNameWithVariant, + blockData, + blockURL, + sectionLibraryMetadata, + defaultLibraryMetadata, + pageMetadata, + index, + }, + }; + + // Set the expected block variant item attributes + blockVariantItem.classList.add('descendant'); + blockVariantItem.setAttribute('data-index', index); + + // Handle sidenav item actions (copy) + blockVariantItem.addEventListener('OnAction', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.dispatchEvent(new CustomEvent('CopyBlock', eventPayload)); + }); + + // Add child variant to parent + blockParentItem.append(blockVariantItem); + + // On item click + blockVariantItem.addEventListener('click', async () => { + if (this.selectedItem) { + this.selectedItem.removeAttribute('selected'); + } + blockVariantItem.setAttribute('selected', true); + this.selectedItem = blockVariantItem; + this.dispatchEvent(new CustomEvent('LoadBlock', eventPayload)); + }); + + // If the block path and index match the URL params, load the block + if (dlPath === path && dlIndex === index.toString()) { + blockParentItem.setAttribute('expanded', true); + this.selectedItem = blockVariantItem; + this.dispatchEvent(new CustomEvent('LoadBlock', eventPayload)); + } + }); + } return blockPromise; } catch (e) { @@ -271,7 +426,7 @@ export class BlockList extends LitElement { } }); - // Wait for all promises to resolve + // Wait for all block loading promises to resolve await Promise.all(promises); // Sort results alphabetically @@ -287,6 +442,13 @@ export class BlockList extends LitElement { return 0; })); + // Seems to be the only way I can set the selected attribute on first load... + setTimeout(() => { + if (this.selectedItem) { + this.selectedItem.setAttribute('selected', true); + } + }, 1); + if (sideNav.querySelectorAll('sp-sidenav-item').length === 0) { container.append(this.renderNoResults()); } diff --git a/src/components/block-renderer/block-renderer.js b/src/components/block-renderer/block-renderer.js index 6ce3818..e1eb2ad 100644 --- a/src/components/block-renderer/block-renderer.js +++ b/src/components/block-renderer/block-renderer.js @@ -15,8 +15,8 @@ import { LitElement, html, css } from 'lit'; import { createRef, ref } from 'lit/directives/ref.js'; import { createTag } from '../../utils/dom.js'; -import AppModel from '../../models/app-model.js'; import { isDev } from '../../utils/library.js'; +import AppModel from '../../models/app-model.js'; export class BlockRenderer extends LitElement { iframe = createRef(); @@ -196,6 +196,11 @@ export class BlockRenderer extends LitElement { el.setAttribute('height', image.height); }); el.src = reader.result; + + // Set all picture sources as well + el.parentElement.querySelectorAll('source').forEach((source) => { + source.setAttribute('srcset', reader.result); + }); }); }); }); @@ -249,7 +254,7 @@ export class BlockRenderer extends LitElement { * @param {HTMLElement} hostContainer The host container to render the iframe into */ // eslint-disable-next-line no-unused-vars - async loadBlock(blockName, blockData, blockWrapper, hostContainer) { + async loadBlock(blockName, blockData, blockWrapper, defaultLibraryMetadata, hostContainer) { const { context } = AppModel.appStore; const { url: blockURL } = blockData; const origin = blockData.extended @@ -270,20 +275,24 @@ export class BlockRenderer extends LitElement { // Assume what we are trying to load is a block and not an autoblock or default content this.isBlock = true; - let block = this.getBlockElement(); + // Assign any block to the content by default + let content = this.getBlockElement(); - // If there is no block, then we are rendering an autoblock or default content - if (!block) { + // If there is no block, then we are rendering an autoblock, default content or a page + // Pages contain blocks so we need to explicity check for page type in default metadata + if (!content || (defaultLibraryMetadata && defaultLibraryMetadata.type === 'template')) { this.isBlock = false; - block = this.getBlockWrapper(); + + // Set the element to the block wrapper instead + content = this.blockWrapperHTML; } // Add the sidekick-library class to the block element const sidekickLibraryClass = 'sidekick-library'; - block?.classList.add(sidekickLibraryClass); + content?.classList.add(sidekickLibraryClass); // Decorate the block with ids - this.decorateEditableElements(block); + this.decorateEditableElements(content); // Clone the block and decorate it const blockClone = blockWrapper.cloneNode(true); @@ -370,8 +379,10 @@ export class BlockRenderer extends LitElement { // Load the block and lazy CSS const codePath = `${origin}${hlx?.codeBasePath ?? ''}`; - // If we are rendering a block, load the block CSS - if (this.isBlock) { + // If we are in dev mode will need to manually load the block CSS + // If we are loading a template the blockName will be an empty string, in this case + // we don't want to load the block CSS + if (isDev() && blockName !== '') { const styleLink = createTag('link', { rel: 'stylesheet', href: `${codePath}/blocks/${blockName}/${blockName}.css` }); frame.contentWindow.document.head.append(styleLink); } diff --git a/src/components/sidenav/sidenav-item.js b/src/components/sidenav/sidenav-item.js index e0524e9..f919111 100644 --- a/src/components/sidenav/sidenav-item.js +++ b/src/components/sidenav/sidenav-item.js @@ -39,6 +39,14 @@ export class SideNavItem extends SPSideNavItem { display: block; } + #item-link .spacer{ + width: 16px; + } + + :host { + padding-right: 5px; + } + :host([expanded]) .disclosureArrow { transform: rotate(90deg); } @@ -83,7 +91,7 @@ export class SideNavItem extends SPSideNavItem { id="item-link" aria-current=${ifDefined(this.selected && this.href ? 'page' : undefined)} > - ${this.disclosureArrow ? html`` : ''} + ${this.disclosureArrow ? html`` : html``}
${this.label} diff --git a/src/plugins/blocks/blocks.js b/src/plugins/blocks/blocks.js index 2bd8236..b3413b5 100644 --- a/src/plugins/blocks/blocks.js +++ b/src/plugins/blocks/blocks.js @@ -18,15 +18,14 @@ no-prototype-builtins */ import { - copyBlock, getBlockName, - getTable, parseDescription, - prepareIconsForCopy, - prepareImagesForCopy, + copyBlockToClipboard, + copyPageToClipboard, + copyDefaultContentToClipboard, } from './utils.js'; import { - createTag, setURLParams, + createTag, removeAllEventListeners, setURLParams, } from '../../utils/dom.js'; import { sampleRUM } from '../../utils/rum.js'; @@ -55,127 +54,266 @@ function renderScaffolding() { `; } -function renderFrameSplitContainer() { - return /* html */` - -
-
- - - - Mobile - - - - Tablet - - - - Desktop - - - -
-
- +/** + * Renders the preview frame including the top action bar, frame view and details container + * @param {HTMLElement} container + */ +function renderFrame(container) { + if (!isFrameLoaded(container)) { + const contentContainer = container.querySelector('.content'); + contentContainer.innerHTML = /* html */` + +
+
+ + + + Mobile + + + + Tablet + + + + Desktop + + + +
+
+ +
-
-
-
-

-
- Copy Block +
+
+

+
+ Copy +
+ +
- -
-
- - `; + + `; + + const actionGroup = container.querySelector('sp-action-group'); + actionGroup.selected = 'desktop'; + + // Setup listeners for the top action bar + const frameView = container.querySelector('.frame-view'); + const mobileViewButton = removeAllEventListeners(container.querySelector('sp-action-button[value="mobile"]')); + mobileViewButton?.addEventListener('click', () => { + frameView.style.width = '480px'; + }); + + const tabletViewButton = removeAllEventListeners(container.querySelector('sp-action-button[value="tablet"]')); + tabletViewButton?.addEventListener('click', () => { + frameView.style.width = '768px'; + }); + + const desktopViewButton = removeAllEventListeners(container.querySelector('sp-action-button[value="desktop"]')); + desktopViewButton?.addEventListener('click', () => { + frameView.style.width = '100%'; + }); + } } -function removeAllEventListeners(element) { - const clone = element.cloneNode(true); - element.parentNode.replaceChild(clone, element); - return clone; +/** + * Checks if the preview frame has been loaded yet + * @param {HTMLElement} container The container that hosts the preview frame + * @returns {Boolean} True if the frame has been loaded, false otherwise + */ +function isFrameLoaded(container) { + return container.querySelector('.details-container') !== null; } /** - * Copies a block to the clipboard - * @param {HTMLElement} wrapper The wrapper element - * @param {string} name The name of the block - * @param {string} blockURL The URL of the block + * Updates the details container with the block title and description + * @param {HTMLElement} container The container containing the details container + * @param {String} title The title of the block + * @param {String} description The description of the block */ -export function copyBlockToClipboard(wrapper, name, blockURL) { - // Get the first block element ignoring any section metadata blocks - const element = wrapper.querySelector(':scope > div:not(.section-metadata)'); - let blockTable = ''; - - // If the wrapper has no block, leave block table empty - if (element) { - blockTable = getTable( - element, - name, - blockURL, - ); +function updateDetailsContainer(container, title, description) { + // Set block title + const blockTitle = container.querySelector('.action-bar .title'); + blockTitle.textContent = title; + + // Set block description + const details = container.querySelector('.details'); + details.innerHTML = ''; + if (description) { + const descriptionElement = createTag('p', {}, description); + details.append(descriptionElement); } +} - // Does the block have section metadata? - let sectionMetadataTable; - const sectionMetadata = wrapper.querySelector('.section-metadata'); - if (sectionMetadata) { - // Create a table for the section metadata - sectionMetadataTable = getTable( - sectionMetadata, - 'Section metadata', - blockURL, - ); - } +/** + * Attaches an event listener to the copy button in the preview UI + * @param {HTMLElement} container The container containing the copy button + * @param {HTMLElement} blockRenderer The block renderer + * @param {Object} defaultLibraryMetadata The default library metadata + * @param {Object} pageMetadata The page metadata + */ +function attachCopyButtonEventListener( + container, + blockRenderer, + defaultLibraryMetadata, + pageMetadata, +) { + const copyButton = removeAllEventListeners(container.querySelector('.content .copy-button')); + copyButton.addEventListener('click', () => { + const copyElement = blockRenderer.getBlockElement(); + const copyWrapper = blockRenderer.getBlockWrapper(); + const copyBlockData = blockRenderer.getBlockData(); + + // Return the copied DOM in the toast message so it can be tested + // Cannot read or write clipboard in tests + let copiedDOM; + + // Are we trying to copy a block, a page or default content? + // The copy operation is slightly different depending on which + if (defaultLibraryMetadata.type === 'template' || defaultLibraryMetadata.multiSectionBlock || defaultLibraryMetadata.compoundBlock) { + copiedDOM = copyPageToClipboard(copyWrapper, copyBlockData.url, pageMetadata); + } else if (blockRenderer.isBlock) { + copiedDOM = copyBlockToClipboard( + copyWrapper, + getBlockName(copyElement, true), + copyBlockData.url, + ); + } else { + copiedDOM = copyDefaultContentToClipboard(copyWrapper, copyBlockData.url); + } - const copied = copyBlock(blockTable, sectionMetadataTable); + container.dispatchEvent(new CustomEvent('Toast', { detail: { message: 'Copied Block', result: copiedDOM } })); + }); +} - // Track block copy event - sampleRUM('library:blockcopied', { target: blockURL }); +function onBlockListCopyButtonClicked(event, container) { + const { + blockWrapper: wrapper, + blockNameWithVariant: name, + blockURL, + defaultLibraryMetadata, + pageMetadata, + } = event.detail; + + // Return the copied DOM in the toast message so it can be tested + // Cannot read or write clipboard in tests + let copiedDOM; + + // We may not have rendered the block yet, so we need to check for a block to know if + // we are dealing with a block or default content + const block = wrapper.querySelector(':scope > div:not(.section-metadata)'); + if (defaultLibraryMetadata && (defaultLibraryMetadata.type === 'template' || defaultLibraryMetadata.multiSectionBlock || defaultLibraryMetadata.compoundBlock)) { + copiedDOM = copyPageToClipboard(wrapper, blockURL, pageMetadata); + } else if (block) { + copiedDOM = copyBlockToClipboard(wrapper, name, blockURL); + } else { + copiedDOM = copyDefaultContentToClipboard(wrapper, blockURL); + } + container.dispatchEvent(new CustomEvent('Toast', { detail: { message: 'Copied Block', target: wrapper, result: copiedDOM } })); +} - return copied; +function loadBlock(event, container) { + const content = container.querySelector('.block-library'); + const { + blockWrapper, + blockData, + sectionLibraryMetadata, + defaultLibraryMetadata, + } = event.detail; + // Block element (first child of the wrapper) + const blockElement = blockWrapper.querySelector('div[class]'); + + // The name of the block (first column of the table) + const blockName = getBlockName(blockElement, false); + + // Render the preview frame if we haven't already + renderFrame(content); + + // For blocks we pull the block name from section metadata or the name given to the block + const authoredBlockName = sectionLibraryMetadata.name ?? getBlockName(blockElement); + + // Pull the description for this block, + // first from sectionLibraryMetadata and fallback to defaultLibraryMetadata + const { description: sectionDescription } = sectionLibraryMetadata; + const blockDescription = sectionDescription + ? parseDescription(sectionDescription) + : parseDescription(defaultLibraryMetadata.description); + + // Set block title & description in UI + updateDetailsContainer(content, authoredBlockName, blockDescription); + + const blockRenderer = content.querySelector('block-renderer'); + + // If the block element exists, load the block + blockRenderer.loadBlock( + blockName, + blockData, + blockWrapper, + defaultLibraryMetadata, + container, + ); + + // Append the path and index of the current block to the url params + setURLParams([['path', blockData.path], ['index', event.detail.index]]); + + // Attach copy button event listener + attachCopyButtonEventListener(container, blockRenderer, defaultLibraryMetadata, undefined); + + // Track block view + sampleRUM('library:blockviewed', { target: blockData.url }); } -/** - * Copies default content to the clipboard - * @param {HTMLElement} wrapper The wrapper element - * @param {string} blockURL The URL of the block - * @returns {HTMLElement} The cloned wrapper - */ -export function copyDefaultContentToClipboard(wrapper, blockURL) { - const wrapperClone = wrapper.cloneNode(true); - prepareIconsForCopy(wrapperClone); - prepareImagesForCopy(wrapperClone, blockURL, 100); - - // Does the block have section metadata? - let sectionMetadataTable; - const sectionMetadata = wrapperClone.querySelector('.section-metadata'); - if (sectionMetadata) { - // Create a table for the section metadata - sectionMetadataTable = getTable( - sectionMetadata, - 'Section metadata', - blockURL, - ); - sectionMetadata.remove(); - } +function loadTemplate(event, container) { + const content = container.querySelector('.block-library'); + const { + blockWrapper, + blockData, + defaultLibraryMetadata, + pageMetadata, + } = event.detail; + + // Render the preview frame if we haven't already + renderFrame(content); + + // For templates we pull the template name from default library metadata + // or the name given to the document in the library sheet. + const authoredTemplateName = defaultLibraryMetadata.name ?? blockData.name; - const copied = copyBlock(wrapperClone.outerHTML, sectionMetadataTable); + // Pull the description for this page from default metadata. + const templateDescription = parseDescription(defaultLibraryMetadata.description); - // Track block copy event - sampleRUM('library:blockcopied', { target: blockURL }); + // Set template title & description in UI + updateDetailsContainer(content, authoredTemplateName, templateDescription); - return copied; + const blockRenderer = content.querySelector('block-renderer'); + + // If the block element exists, load the block + blockRenderer.loadBlock( + '', + blockData, + blockWrapper, + defaultLibraryMetadata, + container, + ); + + // Append the path and index of the current block to the url params + setURLParams([['path', blockData.path]], ['index']); + + // Attach copy button event listener + attachCopyButtonEventListener(container, blockRenderer, defaultLibraryMetadata, pageMetadata); + + // Track block view + sampleRUM('library:blockviewed', { target: blockData.url }); } /** @@ -189,7 +327,6 @@ export async function decorate(container, data) { const content = createTag('div', { class: 'block-library' }, renderScaffolding()); container.append(content); const listContainer = content.querySelector('.list-container'); - let frameLoaded = false; const blockList = createTag('block-list'); listContainer.append(blockList); @@ -198,117 +335,14 @@ export async function decorate(container, data) { window.open(e.details.path, '_blockpreview'); }); - blockList.addEventListener('LoadBlock', (e) => { - const { - blockWrapper, - blockData, - sectionLibraryMetadata, - defaultLibraryMetadata, - } = e.detail; - - const blockElement = blockWrapper.querySelector('div[class]'); - const blockName = getBlockName(blockElement, false); - const authoredBlockName = sectionLibraryMetadata.name ?? getBlockName(blockElement); - - // Pull the description for this block, - // first from sectionLibraryMetadata and fallback to defaultLibraryMetadata - const { description: sectionDescription } = sectionLibraryMetadata; - const blockDescription = sectionDescription - ? parseDescription(sectionDescription) - : parseDescription(defaultLibraryMetadata.description); - - if (!frameLoaded) { - const contentContainer = content.querySelector('.content'); - contentContainer.innerHTML = renderFrameSplitContainer(); - - const actionGroup = content.querySelector('sp-action-group'); - actionGroup.selected = 'desktop'; - frameLoaded = true; - } - - // Set block title - const blockTitle = content.querySelector('.block-title'); - blockTitle.textContent = authoredBlockName; - - // Set block description - const details = content.querySelector('.details'); - details.innerHTML = ''; - if (blockDescription) { - const description = createTag('p', {}, blockDescription); - details.append(description); - } - - const blockRenderer = content.querySelector('block-renderer'); - - // If the block element exists, load the block - blockRenderer.loadBlock( - blockName, - blockData, - blockWrapper, - container, - ); - - // Append the path and index of the current block to the url params - setURLParams([['path', blockData.path], ['index', e.detail.index]]); - - const copyButton = removeAllEventListeners(content.querySelector('.copy-button')); - copyButton.addEventListener('click', () => { - const copyElement = blockRenderer.getBlockElement(); - const copyWrapper = blockRenderer.getBlockWrapper(); - const copyBlockData = blockRenderer.getBlockData(); - - // Return the copied DOM in the toast message so it can be tested - // Cannot read or write clipboard in tests - let copiedDOM; - - // Are we trying to copy a block or default content? - // The copy operation is slightly different depending on which - if (blockRenderer.isBlock) { - copiedDOM = copyBlockToClipboard( - copyWrapper, - getBlockName(copyElement, true), - copyBlockData.url, - ); - } else { - copiedDOM = copyDefaultContentToClipboard(copyWrapper, copyBlockData.url); - } - - container.dispatchEvent(new CustomEvent('Toast', { detail: { message: 'Copied Block', target: copiedDOM } })); - }); - - const frameView = content.querySelector('.frame-view'); - const mobileViewButton = removeAllEventListeners(content.querySelector('sp-action-button[value="mobile"]')); - mobileViewButton?.addEventListener('click', () => { - frameView.style.width = '480px'; - }); - - const tabletViewButton = removeAllEventListeners(content.querySelector('sp-action-button[value="tablet"]')); - tabletViewButton?.addEventListener('click', () => { - frameView.style.width = '768px'; - }); + // Handle LoadTemplate events + blockList.addEventListener('LoadTemplate', loadPageEvent => loadTemplate(loadPageEvent, container)); - const desktopViewButton = removeAllEventListeners(content.querySelector('sp-action-button[value="desktop"]')); - desktopViewButton?.addEventListener('click', () => { - frameView.style.width = '100%'; - }); - - // Track block view - sampleRUM('library:blockviewed', { target: blockData.url }); - }); + // Handle LoadBlock events + blockList.addEventListener('LoadBlock', loadBlockEvent => loadBlock(loadBlockEvent, container)); - blockList.addEventListener('CopyBlock', (e) => { - const { blockWrapper: wrapper, blockNameWithVariant: name, blockURL } = e.detail; - - // We may not have rendered the block yet, so we need to check for a block to know if - // we are dealing with a block or default content - const block = wrapper.querySelector(':scope > div:not(.section-metadata)'); - if (block) { - copyBlockToClipboard(wrapper, name, blockURL); - } else { - copyDefaultContentToClipboard(wrapper, blockURL); - } - container.dispatchEvent(new CustomEvent('Toast', { detail: { message: 'Copied Block', target: wrapper } })); - }); + // Handle CopyBlock events from the block list + blockList.addEventListener('CopyBlock', blockListCopyEvent => onBlockListCopyButtonClicked(blockListCopyEvent, container)); const search = content.querySelector('sp-search'); search.addEventListener('input', (e) => { diff --git a/src/plugins/blocks/utils.js b/src/plugins/blocks/utils.js index f1a064e..cf2e83c 100644 --- a/src/plugins/blocks/utils.js +++ b/src/plugins/blocks/utils.js @@ -16,23 +16,56 @@ import { createCopy, createTag, readBlockConfig, toCamelCase, } from '../../utils/dom.js'; +import { sampleRUM } from '../../utils/rum.js'; +export function blockToObject(blockElement, excludes = [], convertKeys = true) { + if (blockElement) { + const result = {}; + const config = readBlockConfig(blockElement, convertKeys); + Object.keys(config).forEach((key) => { + if (excludes.includes(key)) return; + + if (convertKeys) { + result[toCamelCase(key)] = config[key]; + } else { + result[key] = config[key]; + } + }); + + return result; + } +} + +/** + * Searches for a library metadata block and returns the metadata as an object. + * @param {HTMLElement} block + * @returns {Object} the library metadata + */ export function getLibraryMetadata(block) { const libraryMetadata = block.querySelector('.library-metadata'); - const metadata = {}; if (libraryMetadata) { - const meta = readBlockConfig(libraryMetadata); - Object.keys(meta).forEach((key) => { - if (key === 'style') return; - - metadata[toCamelCase(key)] = meta[key]; - }); + const metadata = blockToObject(libraryMetadata, ['style']); libraryMetadata.remove(); return metadata; } } +/** + * Searches for a page metadata block and returns the metadata as an object. + * @param {HTMLElement} block + * @returns {Object} the page metadata + */ +export function getPageMetadata(block) { + const pageMetadata = block.querySelector('.page-metadata'); + if (pageMetadata) { + const metadata = blockToObject(pageMetadata, [], false); + pageMetadata.remove(); + + return metadata; + } +} + /** * Get the default library metadata for a document. * @param {*} document @@ -68,7 +101,17 @@ export function getBlockName(block, includeVariants = true) { return filteredClasses.length > 0 ? `${name} (${filteredClasses.join(', ')})` : name; } -export function getTable(block, name, path) { +function getPreferedBackgroundColor() { + return getComputedStyle(document.documentElement) + .getPropertyValue('--sk-table-bg-color') || '#ff8012'; +} + +function getPreferedForegroundColor() { + return getComputedStyle(document.documentElement) + .getPropertyValue('--sk-table-fg-color') || '#ffffff'; +} + +export function convertBlockToTable(block, name, path) { const url = new URL(path); prepareIconsForCopy(block); @@ -83,14 +126,8 @@ export function getTable(block, name, path) { table.setAttribute('border', '1'); table.setAttribute('style', 'width:100%;'); - const backgroundColor = getComputedStyle(document.documentElement) - .getPropertyValue('--sk-table-bg-color') || '#ff8012'; - - const foregroundColor = getComputedStyle(document.documentElement) - .getPropertyValue('--sk-table-fg-color') || '#ffffff'; - const headerRow = document.createElement('tr'); - headerRow.append(createTag('td', { colspan: maxCols, style: `background-color: ${backgroundColor}; color: ${foregroundColor};` }, name)); + headerRow.append(createTag('td', { colspan: maxCols, style: `background-color: ${getPreferedBackgroundColor()}; color: ${getPreferedForegroundColor()};` }, name)); table.append(headerRow); rows.forEach((row) => { const columns = [...row.children]; @@ -112,7 +149,34 @@ export function getTable(block, name, path) { }); table.append(tr); }); - return `${table.outerHTML}
`; + return table; +} + +export function convertObjectToTable(name, object) { + const table = document.createElement('table'); + table.setAttribute('border', '1'); + table.setAttribute('style', 'width:100%;'); + + const headerRow = document.createElement('tr'); + headerRow.append(createTag('td', { colspan: 2, style: `background-color: ${getPreferedBackgroundColor()}; color: ${getPreferedForegroundColor()};` }, name)); + table.append(headerRow); + + for (const [key, value] of Object.entries(object)) { + const tr = document.createElement('tr'); + const keyCol = document.createElement('td'); + keyCol.setAttribute('style', 'width: 50%'); + keyCol.innerText = key; + tr.append(keyCol); + + const valueCol = document.createElement('td'); + valueCol.setAttribute('style', 'width: 50%'); + valueCol.innerText = value; + tr.append(valueCol); + + table.append(tr); + } + + return table; } export function prepareImagesForCopy(element, url, columnWidthPercentage) { @@ -192,10 +256,26 @@ export function parseDescription(description) { : description; } -export function copyBlock(block, sectionMetadata) { - const tables = [block]; +/** + * + * @param {*} block + * @param {*} baseURL + * @returns + */ +function getSectionMetadata(block, baseURL) { + const sectionMetadata = block.querySelector(':scope > .section-metadata'); + if (sectionMetadata) { + // Create a table for the section metadata + return convertBlockToTable( + sectionMetadata, + 'Section metadata', + baseURL, + ); + } +} - if (sectionMetadata) tables.push(sectionMetadata); +export function copyBlock(block) { + const tables = [block]; try { const blob = new Blob(tables, { type: 'text/html' }); @@ -205,5 +285,123 @@ export function copyBlock(block, sectionMetadata) { console.error('Unable to copy block', error); } - return tables; + return block; +} + +/** + * Copies a block to the clipboard + * @param {HTMLElement} wrapper The wrapper element + * @param {string} name The name of the block + * @param {string} blockURL The URL of the block + */ +export function copyBlockToClipboard(wrapper, name, blockURL) { + // Get the first block element ignoring any section metadata blocks + const element = wrapper.querySelector(':scope > div:not(.section-metadata)'); + let blockTable = ''; + + // If the wrapper has no block, leave block table empty + if (element) { + blockTable = convertBlockToTable( + element, + name, + blockURL, + ); + } + + // Does the block have section metadata? + const sectionMetadataTable = getSectionMetadata(wrapper, blockURL); + if (sectionMetadataTable) { + sectionMetadataTable.prepend(createTag('br')); + blockTable.append(sectionMetadataTable); + } + + const copied = copyBlock(blockTable.outerHTML); + + // Track block copy event + sampleRUM('library:blockcopied', { target: blockURL }); + + return copied; +} + +/** + * Copies default content to the clipboard + * @param {HTMLElement} wrapper The wrapper element + * @param {string} blockURL The URL of the block + * @returns {HTMLElement} The cloned wrapper + */ +export function copyDefaultContentToClipboard(wrapper, blockURL) { + const wrapperClone = wrapper.cloneNode(true); + prepareIconsForCopy(wrapperClone); + prepareImagesForCopy(wrapperClone, blockURL, 100); + + const sectionMetadataTable = getSectionMetadata(wrapperClone, blockURL); + if (sectionMetadataTable) { + wrapperClone.append(sectionMetadataTable); + + const sectionMetadata = wrapperClone.querySelector(':scope > .section-metadata'); + sectionMetadata.remove(); + } + + const copied = copyBlock(wrapperClone.outerHTML); + + // Track block copy event + sampleRUM('library:blockcopied', { target: blockURL }); + + return copied; +} + +/** + * Copies a page to the clipboard, pages can consist of multiple blocks, + * default content, section metadata and metadata + * @param {HTMLElement} wrapper The wrapper element + * @param {string} blockURL The URL of the block + * @returns {HTMLElement} The cloned wrapper + */ +export function copyPageToClipboard(wrapper, blockURL, pageMetadata) { + const wrapperClone = wrapper.cloneNode(true); + prepareIconsForCopy(wrapperClone); + prepareImagesForCopy(wrapperClone, blockURL, 100); + + const sectionBreak = createTag('p', undefined, '---'); + + // Get all section on page + const sections = wrapperClone.querySelectorAll(':scope > div'); + sections.forEach((section, index) => { + // If not the last section, add a section delimeter + if (index < sections.length - 1) { + section.insertAdjacentElement('beforeend', sectionBreak.cloneNode(true)); + } + + // Does the current section have any blocks? + const blocks = section.querySelectorAll(':scope > div:not(.section-metadata)'); + blocks.forEach((block) => { + // Convert the block to a table + const blockTable = convertBlockToTable( + block, + getBlockName(block, true), + blockURL, + ); + + // Replace the block with the table + block.replaceWith(blockTable); + }); + + const sectionMetadata = section.querySelector(':scope > div.section-metadata'); + const sectionMetadataTable = getSectionMetadata(section, blockURL); + if (sectionMetadataTable) { + sectionMetadata.replaceWith(createTag('br'), sectionMetadataTable); + } + }); + + if (pageMetadata) { + const pageMetadataTable = convertObjectToTable('Metadata', pageMetadata); + wrapperClone.append(pageMetadataTable); + } + + const copied = copyBlock(wrapperClone.outerHTML); + + // Track block copy event + sampleRUM('library:blockcopied', { target: blockURL }); + + return copied; } diff --git a/src/utils/dom.js b/src/utils/dom.js index 7da2034..c4fc462 100644 --- a/src/utils/dom.js +++ b/src/utils/dom.js @@ -120,14 +120,14 @@ export function isPath(str) { * @param {Element} block The block element * @returns {object} The block config */ -export function readBlockConfig(block) { +export function readBlockConfig(block, convertKeys = true) { const config = {}; block.querySelectorAll(':scope > div').forEach((row) => { if (row.children) { const cols = [...row.children]; if (cols[1]) { const col = cols[1]; - const name = toClassName(cols[0].textContent); + const name = convertKeys ? toClassName(cols[0].textContent) : cols[0].textContent; let value = ''; if (col.querySelector('a')) { const as = [...col.querySelectorAll('a')]; @@ -209,11 +209,16 @@ export function createSideNavItem( * Appends the provided url params to the current url * @param {Array} kvs An array of key value pairs */ -export function setURLParams(kvs) { +export function setURLParams(toAdd, toRemove = []) { const url = new URL(window.location.href); - kvs.forEach(([key, value]) => { + toAdd.forEach(([key, value]) => { url.searchParams.set(key, value); }); + + toRemove.forEach((key) => { + url.searchParams.delete(key); + }); + const { href } = url; window.history.pushState({ path: href }, '', decodeURIComponent(href)); } @@ -227,3 +232,14 @@ export function removeAllURLParams() { const newUrl = `${url.origin}${url.pathname}`; window.history.pushState({ path: newUrl }, '', newUrl); } + +/** + * Removes all event listeners from an element by cloning it + * @param {HTMLElement} element The element to remove the listeners from + * @returns The element with the listeners removed + */ +export function removeAllEventListeners(element) { + const clone = element.cloneNode(true); + element.parentNode.replaceChild(clone, element); + return clone; +} diff --git a/test/components/block-renderer/block-renderer.test.js b/test/components/block-renderer/block-renderer.test.js index 5c2b60a..c403806 100644 --- a/test/components/block-renderer/block-renderer.test.js +++ b/test/components/block-renderer/block-renderer.test.js @@ -33,53 +33,48 @@ import { defaultContentPageUrl, mockFetchAllEditableDocumentSuccess, mockFetchCardsDocumentSuccess, + mockFetchCompoundBlockDocumentSuccess, mockFetchDefaultContentDocumentSuccess, mockFetchInlinePageDependenciesSuccess, + mockFetchTabsDocumentSuccess, + tabsContentPageUrl, } from '../../fixtures/pages.js'; import { DEFAULT_CONTENT_STUB } from '../../fixtures/stubs/default-content.js'; +import { + TABS_DEFAULT_STUB_SECTION_1, + TABS_DEFAULT_STUB_SECTION_2, + TABS_DEFAULT_STUB_SECTION_3, + TABS_DEFAULT_STUB_SECTION_4, +} from '../../fixtures/stubs/tabs.js'; +import { createTag } from '../../../src/utils/dom.js'; describe('BlockRenderer', () => { let blockRenderer; - const renderCardsBlock = async (blockRendererMethod) => { - const cardsBlockName = 'cards'; - const cardsBlockData = { - url: cardsPageUrl, - extended: false, - }; - const cardsBlock = mockBlock(CARDS_DEFAULT_STUB, [], true); - - await blockRendererMethod.loadBlock(cardsBlockName, cardsBlockData, cardsBlock); - }; - - const renderAllEditable = async (blockRendererMethod) => { - const allEditableBlockName = 'all-editable-elements'; - const allEditableBlockData = { - url: allEditablePageUrl, + const renderContent = async ( + name, + url, + contentStub, + defaultLibraryMetadata = {}, + wrap = true, + ) => { + const blockData = { + url, extended: false, }; - const allEditableBlock = mockBlock(ALL_EDITABLE_STUB, [], true); - - await blockRendererMethod.loadBlock( - allEditableBlockName, - allEditableBlockData, - allEditableBlock, - ); - }; - - const renderDefaultContent = async (blockRendererMethod) => { - const defaultContentBlockName = 'default content'; - const defaultContentBlockData = { - url: defaultContentPageUrl, - extended: false, - }; - const defaultContent = mockBlock(DEFAULT_CONTENT_STUB, [], false); - - await blockRendererMethod.loadBlock( - defaultContentBlockName, - defaultContentBlockData, - defaultContent, + let block; + if (Array.isArray(contentStub)) { + block = createTag('div', {}, contentStub.map(stubItem => mockBlock(stubItem, [], wrap).outerHTML).join('')); + } else { + block = mockBlock(contentStub, [], wrap); + } + + await blockRenderer.loadBlock( + name, + blockData, + block, + defaultLibraryMetadata, ); }; @@ -101,6 +96,8 @@ describe('BlockRenderer', () => { mockFetchAllEditableDocumentSuccess(); mockFetchInlinePageDependenciesSuccess(); mockFetchDefaultContentDocumentSuccess(); + mockFetchCompoundBlockDocumentSuccess(); + mockFetchTabsDocumentSuccess(); blockRenderer = await fixture(html``); }); @@ -110,8 +107,8 @@ describe('BlockRenderer', () => { }); describe('getBlockElement', () => { - it('returns the block element', () => { - renderCardsBlock(blockRenderer); + it('returns the block element', async () => { + await renderContent('cards', cardsPageUrl, CARDS_DEFAULT_STUB); const blockElement = blockRenderer.getBlockElement(); expect(blockElement).to.exist; expect(blockElement.tagName).to.equal('DIV'); @@ -120,8 +117,8 @@ describe('BlockRenderer', () => { }); describe('getBlockWrapper', () => { - it('returns the block wrapper', () => { - renderCardsBlock(blockRenderer); + it('returns the block wrapper', async () => { + await renderContent('cards', cardsPageUrl, CARDS_DEFAULT_STUB); const blockWrapper = blockRenderer.getBlockWrapper(); expect(blockWrapper).to.exist; expect(blockWrapper.tagName).to.equal('DIV'); @@ -130,8 +127,8 @@ describe('BlockRenderer', () => { }); describe('getBlockData', () => { - it('returns the block data', () => { - renderCardsBlock(blockRenderer); + it('returns the block data', async () => { + await renderContent('cards', cardsPageUrl, CARDS_DEFAULT_STUB); const blockData = blockRenderer.getBlockData(); expect(blockData).to.exist; expect(blockData).to.deep.equal({ @@ -144,7 +141,7 @@ describe('BlockRenderer', () => { describe('decorateEditableElements', () => { it('check for contenteditable and data-library-id', async () => { mockFetchInlinePageDependenciesSuccess('all-editable-elements'); - await renderAllEditable(blockRenderer); + await renderContent('all-editable-elements', allEditablePageUrl, ALL_EDITABLE_STUB); const iframe = blockRenderer.shadowRoot.querySelector('iframe'); await waitUntil( @@ -200,7 +197,7 @@ describe('BlockRenderer', () => { describe('loadBlock', () => { it('should load a block page', async () => { - renderCardsBlock(blockRenderer); + await renderContent('cards', cardsPageUrl, CARDS_DEFAULT_STUB); const iframe = blockRenderer.shadowRoot.querySelector('iframe'); await waitUntil( @@ -208,7 +205,8 @@ describe('BlockRenderer', () => { 'Element did not render children', ); - const cardsBlock = recursiveQuery(iframe.contentDocument, '.cards'); + const { contentDocument } = iframe; + const cardsBlock = contentDocument.querySelector('.cards'); expect(cardsBlock).to.exist; expect(iframe.contentDocument.body.classList.contains('sidekick-library')).to.eq(true); }); @@ -299,7 +297,7 @@ describe('BlockRenderer', () => { describe('default content', () => { it('default content should render', async () => { - await renderDefaultContent(blockRenderer); + await renderContent('default content', defaultContentPageUrl, DEFAULT_CONTENT_STUB); const iframe = blockRenderer.shadowRoot.querySelector('iframe'); await waitUntil( @@ -307,18 +305,50 @@ describe('BlockRenderer', () => { 'Element did not render children', ); - const heading = recursiveQuery(iframe.contentDocument, '#this-is-a-heading'); + const { contentDocument } = iframe; + const heading = contentDocument.querySelector('#this-is-a-heading'); expect(heading).to.exist; - const img = recursiveQuery(iframe.contentDocument, 'img'); + const img = contentDocument.querySelector('img'); expect(img.src).to.equal('https://example.hlx.test/media_1dda29fc47b8402ff940c87a2659813e503b01d2d.png?width=750&format=png&optimize=medium'); - expect(iframe.contentDocument.body.classList.contains('sidekick-library')).to.eq(true); + expect(contentDocument.body.classList.contains('sidekick-library')).to.eq(true); + }); + }); + + describe('multi-section content', () => { + it('multi section blocks should render', async () => { + await renderContent('multi-section content', tabsContentPageUrl, [ + TABS_DEFAULT_STUB_SECTION_1, + TABS_DEFAULT_STUB_SECTION_2, + TABS_DEFAULT_STUB_SECTION_3, + TABS_DEFAULT_STUB_SECTION_4, + ]); + + const iframe = blockRenderer.shadowRoot.querySelector('iframe'); + await waitUntil( + () => recursiveQuery(iframe.contentDocument, '.tabs'), + 'Element did not render children', + ); + + const { contentDocument } = iframe; + + const heading = contentDocument.querySelector('#tab-2-content'); + expect(heading).to.exist; + + const img = contentDocument.querySelector('img'); + expect(img.src).to.equal('https://example.hlx.test/media_1ec4de4b5a7398fdbeb9a2150fb69acc74100e0d0.png?width=750&format=png&optimize=medium'); + + expect(contentDocument.querySelectorAll(':scope main > div > div').length).to.eq(4); + expect(contentDocument.querySelectorAll('table').length).to.eq(0); + expect(contentDocument.querySelectorAll('h2').length).to.eq(4); + expect(contentDocument.querySelector('h2').textContent).to.eq('Tab 2 content'); + expect(contentDocument.querySelectorAll(':scope ol li').length).to.eq(3); }); }); describe('editable content', () => { it('content should be editable', async () => { - await renderCardsBlock(blockRenderer); + await renderContent('cards', cardsPageUrl, CARDS_DEFAULT_STUB); const iframe = blockRenderer.shadowRoot.querySelector('iframe'); await waitUntil( @@ -352,7 +382,7 @@ describe('BlockRenderer', () => { describe('enableImageDragDrop', () => { it('drag events', async () => { - renderCardsBlock(blockRenderer); + await renderContent('cards', cardsPageUrl, CARDS_DEFAULT_STUB); const iframe = blockRenderer.shadowRoot.querySelector('iframe'); await waitUntil( @@ -389,6 +419,10 @@ describe('BlockRenderer', () => { expect(img.style.outlineRadius).to.equal('initial'); expect(img.src).to.equal(IMAGE); + img.parentElement.querySelectorAll('source').forEach((source) => { + expect(source.srcset).to.equal(IMAGE); + }); + img.dispatchEvent(new Event('dragleave', { target: img })); expect(img.style.outline).to.equal('initial'); expect(img.style.outlineRadius).to.equal('initial'); diff --git a/test/fixtures/blocks.js b/test/fixtures/blocks.js index a603423..eb75359 100644 --- a/test/fixtures/blocks.js +++ b/test/fixtures/blocks.js @@ -14,7 +14,15 @@ import fetchMock from 'fetch-mock/esm/client'; import { createTag } from '../../src/utils/dom.js'; import { CARDS_DEFAULT_STUB, CARDS_LOGOS_STUB } from './stubs/cards.js'; import { COLUMNS_CENTER_BACKGROUND_STUB, COLUMNS_DEFAULT_STUB } from './stubs/columns.js'; +import { + TABS_DEFAULT_STUB_SECTION_1, + TABS_DEFAULT_STUB_SECTION_2, + TABS_DEFAULT_STUB_SECTION_3, + TABS_DEFAULT_STUB_SECTION_4, +} from './stubs/tabs.js'; import { DEFAULT_CONTENT_STUB } from './stubs/default-content.js'; +import { COMPOUND_BLOCK_STUB } from './stubs/compound-block.js'; +import { TEMPLATE_STUB } from './stubs/template.js'; export function mockBlock(html, variants = [], wrap = false) { const clone = html.cloneNode(true); @@ -86,7 +94,28 @@ export const mockFetchDefaultContentPlainHTMLSuccess = () => fetchMock.get(defau body: [mockBlock(DEFAULT_CONTENT_STUB, [], false).outerHTML, mockBlock(DEFAULT_CONTENT_STUB, [], false).outerHTML].join('\n'), }); +export const compoundBlockUrl = 'https://example.hlx.test/tools/sidekick/blocks/compound-block/compound-block.plain.html'; +export const mockFetchCompoundBlockPlainHTMLSuccess = () => fetchMock.get(compoundBlockUrl, { + status: 200, + body: [mockBlock(COMPOUND_BLOCK_STUB, [], false).outerHTML].join('\n'), +}); + +export const templateUrl = 'https://example.hlx.test/tools/sidekick/blocks/blog-post/blog-post.plain.html'; +export const mockFetchTemplatePlainHTMLSuccess = () => fetchMock.get(templateUrl, { + status: 200, + body: [mockBlock(TEMPLATE_STUB, [], false).innerHTML].join('\n'), +}); + export const nonExistentBlockUrl = 'https://example.hlx.test/tools/sidekick/blocks/columns/path-does-not-exist.plain.html'; export const mockFetchNonExistantPlainHTMLFailure = () => fetchMock.get(nonExistentBlockUrl, { status: 404, }); + +export const tabsBlockUrl = 'https://example.hlx.test/tools/sidekick/blocks/tabs/tabs.plain.html'; +export const mockFetchTabsPlainHTMLSuccess = () => fetchMock.get(tabsBlockUrl, { + status: 200, + body: [mockBlock(TABS_DEFAULT_STUB_SECTION_1, [], false).outerHTML, + mockBlock(TABS_DEFAULT_STUB_SECTION_2, [], false).outerHTML, + mockBlock(TABS_DEFAULT_STUB_SECTION_3, [], false).outerHTML, + mockBlock(TABS_DEFAULT_STUB_SECTION_4, [], false).outerHTML].join('\n'), +}); diff --git a/test/fixtures/libraries.js b/test/fixtures/libraries.js index 6cbbd75..895605c 100644 --- a/test/fixtures/libraries.js +++ b/test/fixtures/libraries.js @@ -24,6 +24,24 @@ export const COLUMNS_BLOCK_LIBRARY_ITEM = { url: 'https://example.hlx.test/tools/sidekick/blocks/columns/columns', }; +export const TABS_LIBRARY_ITEM = { + name: 'Tabs', + path: '/tools/sidekick/blocks/tabs/tabs', + url: 'https://example.hlx.test/tools/sidekick/blocks/tabs/tabs', +}; + +export const COMPOUND_BLOCK_LIBRARY_ITEM = { + name: 'Compound Block', + path: '/tools/sidekick/blocks/compound-block/compound-block', + url: 'https://example.hlx.test/tools/sidekick/blocks/compound-block/compound-block', +}; + +export const TEMPLATE_LIBRARY_ITEM = { + name: 'Blog Post', + path: '/tools/sidekick/blocks/blog-post/blog-post', + url: 'https://example.hlx.test/tools/sidekick/blocks/blog-post/blog-post', +}; + export const NON_EXISTENT_BLOCK_LIBRARY_ITEM = { name: 'Columns', path: '/tools/sidekick/blocks/columns/path-does-not-exist', @@ -36,6 +54,12 @@ export const DEFAULT_CONTENT_LIBRARY_ITEM = { url: 'https://example.hlx.test/tools/sidekick/blocks/default-content/default-content', }; +export const PAGE_LIBRARY_ITEM = { + name: 'Template', + path: '/tools/sidekick/blocks/page-template/page-template', + url: 'https://example.hlx.test/tools/sidekick/blocks/page-template/page-template', +}; + const constructSingleSheetJSONBody = data => ({ body: { total: data.length, @@ -87,6 +111,12 @@ export const mockFetchSingleSheetLibrarySuccess = () => fetchMock.get(singleShee ...constructSingleSheetJSONBody([CARDS_BLOCK_LIBRARY_ITEM, COLUMNS_BLOCK_LIBRARY_ITEM]), }); +export const sheetWithTemplate = 'https://example.hlx.test/tools/sidekick/library-single-sheet-with-template.json'; +export const mockFetchSheetWithTemplateSuccess = () => fetchMock.get(sheetWithTemplate, { + status: 200, + ...constructSingleSheetJSONBody([TEMPLATE_LIBRARY_ITEM]), +}); + export const unknownPluginSheetUrl = 'https://example.hlx.test/tools/sidekick/unknown-plugin-sheet.json'; export const mockFetchSheetLibraryWithUnknownPluginSuccess = () => { const unknownPlugin = constructPlugin('foobar', [{ one: 'foo' }, { two: 'bar' }]); diff --git a/test/fixtures/pages.js b/test/fixtures/pages.js index aa0ab29..2f37640 100644 --- a/test/fixtures/pages.js +++ b/test/fixtures/pages.js @@ -17,6 +17,14 @@ import { stubHead, stubPage } from './stubs/pages.js'; import { ALL_EDITABLE_STUB } from './stubs/editable.js'; import { DEFAULT_CONTENT_STUB } from './stubs/default-content.js'; import { COLUMNS_CENTER_BACKGROUND_STUB, COLUMNS_DEFAULT_STUB } from './stubs/columns.js'; +import { + TABS_DEFAULT_STUB_SECTION_1, + TABS_DEFAULT_STUB_SECTION_2, + TABS_DEFAULT_STUB_SECTION_3, + TABS_DEFAULT_STUB_SECTION_4, +} from './stubs/tabs.js'; +import { COMPOUND_BLOCK_STUB } from './stubs/compound-block.js'; +import { TEMPLATE_STUB } from './stubs/template.js'; export function mockBlock(html, variants = [], wrap = false) { const clone = html.cloneNode(true); @@ -38,6 +46,27 @@ export const mockFetchColumnsDocumentSuccess = () => fetchMock.get(columnsPageUr body: stubPage(stubHead('columns'), [mockBlock(COLUMNS_DEFAULT_STUB, [], true), mockBlock(COLUMNS_CENTER_BACKGROUND_STUB, [], true)]), }); +export const tabsContentPageUrl = 'https://example.hlx.test/tools/sidekick/blocks/tabs/tabs'; +export const mockFetchTabsDocumentSuccess = () => fetchMock.get(tabsContentPageUrl, { + status: 200, + body: stubPage(stubHead('tabs'), [mockBlock(TABS_DEFAULT_STUB_SECTION_1, [], false), + mockBlock(TABS_DEFAULT_STUB_SECTION_2, [], false), + mockBlock(TABS_DEFAULT_STUB_SECTION_3, [], false), + mockBlock(TABS_DEFAULT_STUB_SECTION_4, [], false)]), +}); + +export const compoundBlockPageUrl = 'https://example.hlx.test/tools/sidekick/blocks/compound-block/compound-block'; +export const mockFetchCompoundBlockDocumentSuccess = () => fetchMock.get(compoundBlockPageUrl, { + status: 200, + body: stubPage(stubHead('compound-block'), [mockBlock(COMPOUND_BLOCK_STUB, [], false)]), +}); + +export const templatePageUrl = 'https://example.hlx.test/tools/sidekick/blocks/blog-post/blog-post'; +export const mockFetchTemplateDocumentSuccess = () => fetchMock.get(templatePageUrl, { + status: 200, + body: stubPage(stubHead('blog-post'), [mockBlock(TEMPLATE_STUB, [], false)]), +}); + export const allEditablePageUrl = 'https://example.hlx.test/tools/sidekick/blocks/alleditable/alleditable'; export const mockFetchAllEditableDocumentSuccess = () => fetchMock.get(allEditablePageUrl, { status: 200, diff --git a/test/fixtures/stubs/compound-block.js b/test/fixtures/stubs/compound-block.js new file mode 100644 index 0000000..e403f2c --- /dev/null +++ b/test/fixtures/stubs/compound-block.js @@ -0,0 +1,99 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { createTag } from '../../../src/utils/dom.js'; + +export const COMPOUND_BLOCK_STUB = createTag('div', {}, /* html */` +
+
+
+ + + + + + +
+
+

Eyebrow

+

Heading

+

Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.

+

learn more

+
+
+
+
+ + + + + + +
+
+

Eyebrow

+

Heading

+

Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.

+

learn more

+
+
+
+
+ + + + + + +
+
+

Eyebrow

+

Heading

+

Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.

+

learn more

+
+
+
+ + + `); diff --git a/test/fixtures/stubs/pages.js b/test/fixtures/stubs/pages.js index 08125e1..200b7b2 100644 --- a/test/fixtures/stubs/pages.js +++ b/test/fixtures/stubs/pages.js @@ -25,7 +25,7 @@ export const stubHead = (blockName = 'cards') => /* html */` - + `; diff --git a/test/fixtures/stubs/tabs.js b/test/fixtures/stubs/tabs.js new file mode 100644 index 0000000..02d1270 --- /dev/null +++ b/test/fixtures/stubs/tabs.js @@ -0,0 +1,138 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { createTag } from '../../../src/utils/dom.js'; + +export const TABS_DEFAULT_STUB_SECTION_1 = createTag('div', {}, /* html */` +
+
+
+
+
    +
  1. First tab title
  2. +
  3. Second tab title
  4. +
  5. Third tab title
  6. +
+
+
+
+
Active tab
+
1
+
+
+
id
+
tab-demo
+
+
+ +
+`); + +export const TABS_DEFAULT_STUB_SECTION_2 = createTag('div', {}, /* html */` +
+

Tab 1 content

+ +
`); + +export const TABS_DEFAULT_STUB_SECTION_3 = createTag('div', {}, /* html */` +
+

Tab 2 content

+
+
+
+ + + + + + +
+
+

Eyebrow

+

Heading

+

Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.

+

learn more

+
+
+
+
+ + + + + + +
+
+

Eyebrow

+

Heading

+

Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.

+

learn more

+
+
+
+
+ + + + + + +
+
+

Eyebrow

+

Heading

+

Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.

+

learn more

+
+
+
+ +
`); + +export const TABS_DEFAULT_STUB_SECTION_4 = createTag('div', {}, /* html */` +
+

Tab 3 content

+ +
+
+ +
`); diff --git a/test/fixtures/stubs/template.js b/test/fixtures/stubs/template.js new file mode 100644 index 0000000..e21625c --- /dev/null +++ b/test/fixtures/stubs/template.js @@ -0,0 +1,92 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { createTag } from '../../../src/utils/dom.js'; + +export const TEMPLATE_STUB = createTag('div', {}, /* html */` +
+

My blog post about a subject

+

+ + + + + + +

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+
+
+
+

“I believe, ‘Why?’ is one of the most creative questions a person can ask. People generally think of science and creativity as opposing forces, but when I’m developing a hypothesis about Adobe’s customers, I’m drawing on all the knowledge and imagination at my disposal to see the world through their eyes.”

+

- Steve Smith

+
+
+
+

Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

+ +
+
+ +
+
+ +
+`); diff --git a/test/franklin-library.test.js b/test/franklin-library.test.js index 7d48c26..bb5e0d6 100644 --- a/test/franklin-library.test.js +++ b/test/franklin-library.test.js @@ -14,7 +14,7 @@ import { html } from 'lit'; import { - fixture, expect, waitUntil, + fixture, expect, waitUntil, fixtureCleanup, } from '@open-wc/testing'; import fetchMock from 'fetch-mock/esm/client'; import { recursiveQuery, recursiveQueryAll, simulateTyping } from './test-utils.js'; @@ -29,11 +29,13 @@ import { multiSheetUrl, mockFetchSheetLibraryWithUnknownPluginSuccess, unknownPluginSheetUrl, + sheetWithTemplate, + mockFetchSheetWithTemplateSuccess, } from './fixtures/libraries.js'; import { mockFetchEnLocalesSuccess } from './fixtures/locales.js'; -import { mockFetchCardsPlainHTMLSuccess, mockFetchColumnsPlainHTMLSuccess } from './fixtures/blocks.js'; +import { mockFetchCardsPlainHTMLSuccess, mockFetchColumnsPlainHTMLSuccess, mockFetchTemplatePlainHTMLSuccess } from './fixtures/blocks.js'; import { setURLParams } from '../src/utils/dom.js'; -import { mockFetchColumnsDocumentSuccess, mockFetchInlinePageDependenciesSuccess } from './fixtures/pages.js'; +import { mockFetchColumnsDocumentSuccess, mockFetchInlinePageDependenciesSuccess, mockFetchTemplateDocumentSuccess } from './fixtures/pages.js'; describe('FranklinLibrary', () => { beforeEach(() => { @@ -44,10 +46,14 @@ describe('FranklinLibrary', () => { mockFetchCardsPlainHTMLSuccess(); mockFetchColumnsPlainHTMLSuccess(); mockFetchColumnsDocumentSuccess(); + mockFetchSheetWithTemplateSuccess(); + mockFetchTemplateDocumentSuccess(); + mockFetchTemplatePlainHTMLSuccess(); }); afterEach(() => { fetchMock.restore(); + fixtureCleanup(); }); it('renders container', async () => { @@ -225,7 +231,12 @@ describe('FranklinLibrary', () => { expect(expandedItem).to.not.be.null; expect(expandedItem.getAttribute('label')).to.equal('Columns'); - const blockTitle = recursiveQuery(library, '.block-title'); + await waitUntil( + () => recursiveQuery(library, '.details-container .action-bar .title'), + 'Element did not render children', + ); + + const blockTitle = recursiveQuery(library, '.details-container .action-bar .title'); expect(blockTitle.textContent).to.equal('columns (center, background)'); const blockRenderer = recursiveQuery(library, 'block-renderer'); @@ -240,6 +251,49 @@ describe('FranklinLibrary', () => { expect(h2.textContent).to.equal('Lorem Ipsum'); }); + it('deep linking to a template', async () => { + console.log('my test'); + setURLParams([['plugin', 'blocks'], ['path', '/tools/sidekick/blocks/blog-post/blog-post']], ['index']); + const library = document.createElement('sidekick-library'); + library.config = { + base: sheetWithTemplate, + }; + + await fixture(library); + + await waitUntil( + () => recursiveQuery(library, 'sp-menu-item'), + 'Element did not render children', + ); + + await waitUntil( + () => recursiveQuery(library, 'sp-sidenav-item[expanded]'), + 'Element did not render children', + ); + + const expandedItem = recursiveQuery(library, 'sp-sidenav-item[expanded]'); + expect(expandedItem).to.not.be.null; + expect(expandedItem.getAttribute('label')).to.equal('Templates'); + + await waitUntil( + () => recursiveQuery(library, '.action-bar .title'), + 'Element did not render children', + ); + + const blockTitle = recursiveQuery(library, '.details-container .action-bar .title'); + expect(blockTitle.textContent).to.equal('Blog Post Template'); + + const blockRenderer = recursiveQuery(library, 'block-renderer'); + const iframe = blockRenderer.shadowRoot.querySelector('iframe'); + await waitUntil( + () => recursiveQuery(iframe.contentDocument, '.blockquote'), + 'Element did not render children', + ); + + const blockquote = iframe.contentDocument.querySelector('.blockquote'); + expect(blockquote).to.exist; + }); + it('deep linking to tags plugin', async () => { mockFetchInlinePageDependenciesSuccess('columns'); setURLParams([['plugin', 'tags']]); diff --git a/test/plugins/blocks/blocks.test.js b/test/plugins/blocks/blocks.test.js index a46bf26..b7c95db 100644 --- a/test/plugins/blocks/blocks.test.js +++ b/test/plugins/blocks/blocks.test.js @@ -23,7 +23,8 @@ import '../../../src/views/plugin-renderer/plugin-renderer.js'; import '../../../src/components/block-list/block-list.js'; import '../../../src/components/block-renderer/block-renderer.js'; import '../../../src/components/split-view/split-view.js'; -import { copyBlockToClipboard, copyDefaultContentToClipboard, decorate } from '../../../src/plugins/blocks/blocks.js'; +import { copyBlockToClipboard, copyDefaultContentToClipboard } from '../../../src/plugins/blocks/utils.js'; +import { decorate } from '../../../src/plugins/blocks/blocks.js'; import { APP_EVENTS, PLUGIN_EVENTS } from '../../../src/events/events.js'; import { EventBus } from '../../../src/events/eventbus.js'; import AppModel from '../../../src/models/app-model.js'; @@ -37,18 +38,27 @@ import { mockFetchCardsPlainHTMLWithDefaultLibraryMetadataSuccess, mockFetchColumnsPlainHTMLSuccess, mockFetchDefaultContentPlainHTMLSuccess, + mockFetchTabsPlainHTMLSuccess, mockFetchNonExistantPlainHTMLFailure, + mockFetchCompoundBlockPlainHTMLSuccess, + mockFetchTemplatePlainHTMLSuccess, } from '../../fixtures/blocks.js'; import { CARDS_BLOCK_LIBRARY_ITEM, COLUMNS_BLOCK_LIBRARY_ITEM, + COMPOUND_BLOCK_LIBRARY_ITEM, DEFAULT_CONTENT_LIBRARY_ITEM, NON_EXISTENT_BLOCK_LIBRARY_ITEM, + TABS_LIBRARY_ITEM, + TEMPLATE_LIBRARY_ITEM, } from '../../fixtures/libraries.js'; import { mockFetchCardsDocumentSuccess, + mockFetchCompoundBlockDocumentSuccess, mockFetchDefaultContentDocumentSuccess, mockFetchInlinePageDependenciesSuccess, + mockFetchTabsDocumentSuccess, + mockFetchTemplateDocumentSuccess, } from '../../fixtures/pages.js'; import { DEFAULT_CONTENT_STUB_WITH_SECTION_METADATA } from '../../fixtures/stubs/default-content.js'; import { CARDS_DEFAULT_STUB } from '../../fixtures/stubs/cards.js'; @@ -146,6 +156,121 @@ describe('Blocks Plugin', () => { expect(p.getAttribute('data-library-id')).to.not.be.null; }; + const loadMultiSectionContent = async () => { + mockFetchTabsPlainHTMLSuccess(); + mockFetchTabsDocumentSuccess(); + mockFetchInlinePageDependenciesSuccess(); + + const loadBlockSpy = sinon.spy(); + const mockData = [TABS_LIBRARY_ITEM]; + + await decorate(container, mockData); + const blockLibrary = container.querySelector('.block-library'); + const blockList = blockLibrary.querySelector('sp-split-view .menu .list-container block-list'); + blockList.addEventListener('LoadBlock', loadBlockSpy); + + const sidenav = blockList.shadowRoot.querySelector(':scope sp-sidenav'); + + const item = sidenav.querySelector(':scope > sp-sidenav-item[label="Tabs"]'); + const firstCardChild = item.querySelector(':scope > sp-sidenav-item'); + firstCardChild.dispatchEvent(new Event('click')); + + expect(loadBlockSpy.calledOnce).to.be.true; + + const blockRenderer = blockLibrary.querySelector('sp-split-view .content .view .frame-view block-renderer'); + await waitUntil( + () => blockRenderer.shadowRoot.querySelector('iframe'), + 'Element did not render children', + ); + + const iframe = blockRenderer.shadowRoot.querySelector('iframe'); + expect(iframe).to.not.be.null; + + await waitUntil( + () => iframe.contentDocument.querySelector('div.tabs'), + 'Element did not render children', + ); + + const img = iframe.contentDocument.querySelector('img'); + expect(img.src).to.eq('https://example.hlx.test/media_1ec4de4b5a7398fdbeb9a2150fb69acc74100e0d0.png?width=750&format=png&optimize=medium'); + }; + + const loadCompoundBlockContent = async () => { + mockFetchCompoundBlockPlainHTMLSuccess(); + mockFetchCompoundBlockDocumentSuccess(); + mockFetchInlinePageDependenciesSuccess(); + + const loadBlockSpy = sinon.spy(); + const mockData = [COMPOUND_BLOCK_LIBRARY_ITEM]; + + await decorate(container, mockData); + const blockLibrary = container.querySelector('.block-library'); + const blockList = blockLibrary.querySelector('sp-split-view .menu .list-container block-list'); + blockList.addEventListener('LoadBlock', loadBlockSpy); + + const sidenav = blockList.shadowRoot.querySelector(':scope sp-sidenav'); + + const item = sidenav.querySelector(':scope > sp-sidenav-item[label="Compound Block"]'); + const firstCardChild = item.querySelector(':scope > sp-sidenav-item'); + firstCardChild.dispatchEvent(new Event('click')); + + expect(loadBlockSpy.calledOnce).to.be.true; + + const blockRenderer = blockLibrary.querySelector('sp-split-view .content .view .frame-view block-renderer'); + await waitUntil( + () => blockRenderer.shadowRoot.querySelector('iframe'), + 'Element did not render children', + ); + + const iframe = blockRenderer.shadowRoot.querySelector('iframe'); + expect(iframe).to.not.be.null; + + await waitUntil( + () => iframe.contentDocument.querySelector('div.z-pattern'), + 'Element did not render children', + ); + + const img = iframe.contentDocument.querySelector('img'); + expect(img.src).to.eq('https://example.hlx.test/media_1ec4de4b5a7398fdbeb9a2150fb69acc74100e0d0.png?width=750&format=png&optimize=medium'); + }; + + const loadTemplateContent = async () => { + mockFetchTemplatePlainHTMLSuccess(); + mockFetchTemplateDocumentSuccess(); + mockFetchInlinePageDependenciesSuccess(); + + const loadBlockSpy = sinon.spy(); + const mockData = [TEMPLATE_LIBRARY_ITEM]; + + await decorate(container, mockData); + const blockLibrary = container.querySelector('.block-library'); + const blockList = blockLibrary.querySelector('sp-split-view .menu .list-container block-list'); + blockList.addEventListener('LoadBlock', loadBlockSpy); + + const sidenav = blockList.shadowRoot.querySelector(':scope sp-sidenav'); + + const item = sidenav.querySelector(':scope > sp-sidenav-item[label="Templates"]'); + const firstCardChild = item.querySelector(':scope > sp-sidenav-item'); + firstCardChild.dispatchEvent(new Event('click')); + + const blockRenderer = blockLibrary.querySelector('sp-split-view .content .view .frame-view block-renderer'); + await waitUntil( + () => blockRenderer.shadowRoot.querySelector('iframe'), + 'Element did not render children', + ); + + const iframe = blockRenderer.shadowRoot.querySelector('iframe'); + expect(iframe).to.not.be.null; + + await waitUntil( + () => iframe.contentDocument.querySelector('.blockquote'), + 'Element did not render children', + ); + + const img = iframe.contentDocument.querySelector('img'); + expect(img.src).to.eq('https://example.hlx.test/media_1e24f72d6bb08f4cec2618ef688691fe591e57746.jpeg?width=750&format=jpeg&optimize=medium'); + }; + beforeEach(async () => { AppModel.init(); container = await fixture(html``); @@ -281,10 +406,10 @@ describe('Blocks Plugin', () => { }); it('should copy default content from block-list', async () => { - mockFetchCardsPlainHTMLSuccess(); + mockFetchDefaultContentPlainHTMLSuccess(); const toastSpy = sinon.spy(); const copyBlockSpy = sinon.spy(); - const mockData = [CARDS_BLOCK_LIBRARY_ITEM]; + const mockData = [DEFAULT_CONTENT_LIBRARY_ITEM]; await decorate(container, mockData); const blockLibrary = container.querySelector('.block-library'); @@ -293,8 +418,7 @@ describe('Blocks Plugin', () => { container.addEventListener('Toast', toastSpy); const sidenav = blockList.shadowRoot.querySelector(':scope sp-sidenav'); - - const cardsItem = sidenav.querySelector(':scope > sp-sidenav-item[label="Cards"]'); + const cardsItem = sidenav.querySelector(':scope > sp-sidenav-item[label="Default Content"]'); const firstCardChild = cardsItem.querySelector(':scope > sp-sidenav-item'); firstCardChild.dispatchEvent(new Event('OnAction')); @@ -302,6 +426,104 @@ describe('Blocks Plugin', () => { expect(toastSpy.calledOnce).to.be.true; }); + it('should copy multi section block from block-list', async () => { + mockFetchTabsPlainHTMLSuccess(); + const toastSpy = sinon.spy(); + const copyBlockSpy = sinon.spy(); + const mockData = [TABS_LIBRARY_ITEM]; + + await decorate(container, mockData); + const blockLibrary = container.querySelector('.block-library'); + const blockList = blockLibrary.querySelector('sp-split-view .menu .list-container block-list'); + blockList.addEventListener('CopyBlock', copyBlockSpy); + container.addEventListener('Toast', toastSpy); + + const sidenav = blockList.shadowRoot.querySelector(':scope sp-sidenav'); + + const tabsItem = sidenav.querySelector(':scope > sp-sidenav-item[label="Tabs"]'); + const firstCardChild = tabsItem.querySelector(':scope > sp-sidenav-item'); + firstCardChild.dispatchEvent(new Event('OnAction')); + + expect(copyBlockSpy.calledOnce).to.be.true; + expect(toastSpy.calledOnce).to.be.true; + + const copiedHTML = createTag('div', undefined, toastSpy.firstCall.args[0].detail.result); + expect(copiedHTML.querySelectorAll(':scope > div').length).to.eq(4); + expect(copiedHTML.querySelectorAll(':scope table').length).to.eq(5); + expect(copiedHTML.querySelector(':scope h2').textContent).to.eq('Heading'); + expect(copiedHTML.querySelectorAll(':scope ol li').length).to.eq(3); + expect(copiedHTML.querySelector('img').src).to.eq('https://example.hlx.test/media_1ec4de4b5a7398fdbeb9a2150fb69acc74100e0d0.png?width=750&format=png&optimize=medium'); + }); + + it('should copy compound block from block-list', async () => { + mockFetchCompoundBlockPlainHTMLSuccess(); + const toastSpy = sinon.spy(); + const copyBlockSpy = sinon.spy(); + const mockData = [COMPOUND_BLOCK_LIBRARY_ITEM]; + + await decorate(container, mockData); + const blockLibrary = container.querySelector('.block-library'); + const blockList = blockLibrary.querySelector('sp-split-view .menu .list-container block-list'); + blockList.addEventListener('CopyBlock', copyBlockSpy); + container.addEventListener('Toast', toastSpy); + + const sidenav = blockList.shadowRoot.querySelector(':scope sp-sidenav'); + + const tabsItem = sidenav.querySelector(':scope > sp-sidenav-item[label="Compound Block"]'); + const firstCardChild = tabsItem.querySelector(':scope > sp-sidenav-item'); + firstCardChild.dispatchEvent(new Event('OnAction')); + + expect(copyBlockSpy.calledOnce).to.be.true; + expect(toastSpy.calledOnce).to.be.true; + + const copiedHTML = createTag('div', undefined, toastSpy.firstCall.args[0].detail.result); + + expect(copiedHTML.querySelectorAll(':scope > div').length).to.eq(1); + expect(copiedHTML.querySelectorAll(':scope table').length).to.eq(3); + expect(copiedHTML.querySelector('table:nth-of-type(1) tr td').textContent).to.eq('z-pattern'); + expect(copiedHTML.querySelector('table:nth-of-type(2) tr td').textContent).to.eq('banner (small, left)'); + expect(copiedHTML.querySelector('table:nth-of-type(3) tr td').textContent).to.eq('Section metadata'); + }); + + it('should copy template from block-list', async () => { + mockFetchTemplatePlainHTMLSuccess(); + const toastSpy = sinon.spy(); + const copyBlockSpy = sinon.spy(); + const mockData = [TEMPLATE_LIBRARY_ITEM]; + + await decorate(container, mockData); + const blockLibrary = container.querySelector('.block-library'); + const blockList = blockLibrary.querySelector('sp-split-view .menu .list-container block-list'); + blockList.addEventListener('CopyBlock', copyBlockSpy); + container.addEventListener('Toast', toastSpy); + + const sidenav = blockList.shadowRoot.querySelector(':scope sp-sidenav'); + + const tabsItem = sidenav.querySelector(':scope > sp-sidenav-item[label="Templates"]'); + const firstCardChild = tabsItem.querySelector(':scope > sp-sidenav-item'); + firstCardChild.dispatchEvent(new Event('OnAction')); + + expect(copyBlockSpy.calledOnce).to.be.true; + expect(toastSpy.calledOnce).to.be.true; + + const copiedHTML = createTag('div', undefined, toastSpy.firstCall.args[0].detail.result); + expect(copiedHTML.querySelectorAll(':scope > div').length).to.eq(2); + expect(copiedHTML.querySelectorAll(':scope table').length).to.eq(3); + expect(copiedHTML.querySelector('table:nth-of-type(1) tr td').textContent).to.eq('blockquote'); + expect(copiedHTML.querySelector('table:nth-of-type(2) tr td').textContent).to.eq('Section metadata'); + + // eslint-disable-next-line max-len + // expect(copiedHTML.querySelector('table:nth-of-type(2) tr td').textContent).to.eq('Metadata'); + + // Not sure why I had to do this.. makes no sense.. + // There should be 3 tables as per assert above. + copiedHTML.querySelectorAll(':scope table').forEach((table, index) => { + if (index === 2) { + expect(table.querySelector('tr td').textContent).to.eq('Metadata'); + } + }); + }); + it('copy block via details panel', async () => { const toastSpy = sinon.spy(); await loadBlock(); @@ -315,7 +537,7 @@ describe('Blocks Plugin', () => { expect(toastSpy.calledOnce).to.be.true; - const copiedHTML = createTag('div', undefined, toastSpy.firstCall.args[0].detail.target[0]); + const copiedHTML = createTag('div', undefined, toastSpy.firstCall.args[0].detail.result); expect(copiedHTML.querySelector('p:first-of-type').textContent).to.eq('Unmatched speed'); const firstImage = copiedHTML.querySelector('img'); @@ -337,20 +559,93 @@ describe('Blocks Plugin', () => { expect(toastSpy.calledOnce).to.be.true; - const copiedHTML = createTag('div', undefined, toastSpy.firstCall.args[0].detail.target[0]); + const copiedHTML = createTag('div', undefined, toastSpy.firstCall.args[0].detail.result); expect(copiedHTML.querySelector('h1').textContent).to.eq('This is a heading'); expect(copiedHTML.querySelector('p:last-of-type').textContent).to.eq(':home:'); expect(copiedHTML.querySelector('img').src).to.eq('https://example.hlx.test/media_1dda29fc47b8402ff940c87a2659813e503b01d2d.png?width=750&format=png&optimize=medium'); }); + it('copy multi section block via details panel', async () => { + const toastSpy = sinon.spy(); + await loadMultiSectionContent(); + + const blockLibrary = container.querySelector('.block-library'); + container.addEventListener('Toast', toastSpy); + + const actionBar = blockLibrary.querySelector('sp-split-view .content .details-container .action-bar'); + const copyButton = actionBar.querySelector('sp-button'); + copyButton.dispatchEvent(new Event('click')); + + expect(toastSpy.calledOnce).to.be.true; + + const copiedHTML = createTag('div', undefined, toastSpy.firstCall.args[0].detail.result); + expect(copiedHTML.querySelectorAll(':scope > div').length).to.eq(4); + expect(copiedHTML.querySelectorAll(':scope table').length).to.eq(5); + expect(copiedHTML.querySelector(':scope h2').textContent).to.eq('Heading'); + expect(copiedHTML.querySelectorAll(':scope ol li').length).to.eq(3); + expect(copiedHTML.querySelector('img').src).to.eq('https://example.hlx.test/media_1ec4de4b5a7398fdbeb9a2150fb69acc74100e0d0.png?width=750&format=png&optimize=medium'); + }); + + it('copy compound block via details panel', async () => { + const toastSpy = sinon.spy(); + await loadCompoundBlockContent(); + + const blockLibrary = container.querySelector('.block-library'); + container.addEventListener('Toast', toastSpy); + + const actionBar = blockLibrary.querySelector('sp-split-view .content .details-container .action-bar'); + const copyButton = actionBar.querySelector('sp-button'); + copyButton.dispatchEvent(new Event('click')); + + expect(toastSpy.calledOnce).to.be.true; + + const copiedHTML = createTag('div', undefined, toastSpy.firstCall.args[0].detail.result); + expect(copiedHTML.querySelectorAll(':scope > div').length).to.eq(1); + expect(copiedHTML.querySelectorAll(':scope table').length).to.eq(3); + expect(copiedHTML.querySelector(':scope h2').textContent).to.eq('Heading'); + expect(copiedHTML.querySelector('img').src).to.eq('https://example.hlx.test/media_1ec4de4b5a7398fdbeb9a2150fb69acc74100e0d0.png?width=750&format=png&optimize=medium'); + expect(copiedHTML.querySelector('table:nth-of-type(1) tr td').textContent).to.eq('z-pattern'); + expect(copiedHTML.querySelector('table:nth-of-type(2) tr td').textContent).to.eq('banner (small, left)'); + expect(copiedHTML.querySelector('table:nth-of-type(3) tr td').textContent).to.eq('Section metadata'); + }); + + it('copy template via details panel', async () => { + const toastSpy = sinon.spy(); + await loadTemplateContent(); + + const blockLibrary = container.querySelector('.block-library'); + container.addEventListener('Toast', toastSpy); + + const actionBar = blockLibrary.querySelector('sp-split-view .content .details-container .action-bar'); + const copyButton = actionBar.querySelector('sp-button'); + copyButton.dispatchEvent(new Event('click')); + + expect(toastSpy.calledOnce).to.be.true; + + const copiedHTML = createTag('div', undefined, toastSpy.firstCall.args[0].detail.result); + expect(copiedHTML.querySelectorAll(':scope > div').length).to.eq(2); + expect(copiedHTML.querySelectorAll(':scope table').length).to.eq(3); + expect(copiedHTML.querySelector(':scope h1').textContent).to.eq('My blog post about a subject'); + expect(copiedHTML.querySelector('img').src).to.eq('https://example.hlx.test/media_1e24f72d6bb08f4cec2618ef688691fe591e57746.jpeg?width=750&format=jpeg&optimize=medium'); + expect(copiedHTML.querySelector('table:nth-of-type(1) tr td').textContent).to.eq('blockquote'); + expect(copiedHTML.querySelector('table:nth-of-type(2) tr td').textContent).to.eq('Section metadata'); + + // See above for this sillyness + copiedHTML.querySelectorAll(':scope table').forEach((table, index) => { + if (index === 2) { + expect(table.querySelector('tr td').textContent).to.eq('Metadata'); + } + }); + }); + it('copyBlockToClipboard', async () => { const defaultCardsBlock = mockBlock(CARDS_DEFAULT_STUB, [], true); addSectionMetadata(defaultCardsBlock, { style: 'dark' }); const wrapper = defaultCardsBlock; const copied = copyBlockToClipboard(wrapper, 'cards', cardsBlockUrl); - const copiedHTML = createTag('div', undefined, copied[0]); - const copiedMetadata = createTag('div', undefined, copied[1]); + const copiedHTML = createTag('div', undefined, copied); + const copiedMetadata = createTag('div', undefined, copiedHTML.querySelector('table:nth-of-type(2)')); const img = wrapper.querySelector('img'); expect(img.src).to.eq('https://example.hlx.test/media_1.jpeg?width=750&format=jpeg&optimize=medium'); @@ -375,8 +670,8 @@ describe('Blocks Plugin', () => { const url = new URL(defaultContentBlockUrl); const copied = copyDefaultContentToClipboard(wrapper, url); - const copiedHTML = createTag('div', undefined, copied[0]); - const copiedMetadata = createTag('div', undefined, copied[1]); + const copiedHTML = createTag('div', undefined, copied); + const copiedMetadata = createTag('div', undefined, copiedHTML.querySelector('table')); expect(copiedHTML.querySelector('img').src).to.eq('https://example.hlx.test/media_1dda29fc47b8402ff940c87a2659813e503b01d2d.png?width=750&format=png&optimize=medium'); expect(copiedHTML.querySelector('p:last-of-type').textContent).to.eq(':home:');