diff --git a/src/Hook/ReduxIntegratedFieldWidgetsHooks.php b/src/Hook/ReduxIntegratedFieldWidgetsHooks.php index 5c85b996779b1a2dbc2730218ccaac72bab220e3..204e4aec06b52fc2cb0208c77652e0dcb39d0fb3 100644 --- a/src/Hook/ReduxIntegratedFieldWidgetsHooks.php +++ b/src/Hook/ReduxIntegratedFieldWidgetsHooks.php @@ -111,6 +111,13 @@ class ReduxIntegratedFieldWidgetsHooks implements TrustedCallbackInterface { '.media-library-wrapper', '.ui-dialog', ]); + + // Most hidden fields are read only. Add an attribute that allows it to be + // updated and tracked in Redux form state. + if (isset($form['selection'][0]['target_id'])) { + $form['selection'][0]['target_id']['#attributes']['data-track-hidden-value'] = 'true'; + } + } // Use an XB-specific media library opener, because the default opener assumes // the media library is opened for a field widget of a field instance on the diff --git a/tests/modules/xb_test_e2e_code_components/config/install/experience_builder.js_component.xb_test_e2e_code_components_optional_image.yml b/tests/modules/xb_test_e2e_code_components/config/install/experience_builder.js_component.xb_test_e2e_code_components_optional_image.yml new file mode 100644 index 0000000000000000000000000000000000000000..92aa3e3191fefa4e71f4d22e540aff6605c9bb22 --- /dev/null +++ b/tests/modules/xb_test_e2e_code_components/config/install/experience_builder.js_component.xb_test_e2e_code_components_optional_image.yml @@ -0,0 +1,31 @@ +langcode: en +status: true +dependencies: { } +machineName: xb_test_e2e_code_components_optional_image +name: CC Optional Image +block_override: null +required: { } +props: + image: + title: image + type: object + examples: + - + src: 'https://placehold.co/1200x900@2x.png' + width: 1200 + height: 900 + alt: 'Example image placeholder' + $ref: 'json-schema-definitions://experience_builder.module/image' + text: + title: text + type: string + examples: + - 'Some text' +slots: { } +js: + original: "const Image = ({ image, text,}) => { return ( <div> <img {...image} \/> <p>{text}<\/p> <\/div> );};\n\nexport default Image;" + compiled: "function _define_property(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj;}function _object_spread(target) { for(var i = 1; i < arguments.length; i++){ var source = arguments[i] != null ? arguments[i] : {}; var ownKeys = Object.keys(source); if (typeof Object.getOwnPropertySymbols === \"function\") { ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function(sym) { return Object.getOwnPropertyDescriptor(source, sym).enumerable; })); } ownKeys.forEach(function(key) { _define_property(target, key, source[key]); }); } return target;}import { jsx as _jsx, jsxs as _jsxs } from \"react\/jsx-runtime\";const Image = ({ image, text })=>{ return \/*#__PURE__*\/ _jsxs(\"div\", { children: [ \/*#__PURE__*\/ _jsx(\"img\", _object_spread({}, image)), \/*#__PURE__*\/ _jsx(\"p\", { children: text }) ] });};export default Image;" +css: + original: '' + compiled: '' + diff --git a/tests/modules/xb_test_e2e_code_components/config/install/experience_builder.js_component.xb_test_e2e_code_components_req_image.yml b/tests/modules/xb_test_e2e_code_components/config/install/experience_builder.js_component.xb_test_e2e_code_components_req_image.yml new file mode 100644 index 0000000000000000000000000000000000000000..cb4fbff5539798985e5928569ba04a565b656e63 --- /dev/null +++ b/tests/modules/xb_test_e2e_code_components/config/install/experience_builder.js_component.xb_test_e2e_code_components_req_image.yml @@ -0,0 +1,32 @@ +langcode: en +status: true +dependencies: { } +machineName: xb_test_e2e_code_components_req_image +name: CC Required Image +block_override: null +required: + - image +props: + image: + title: image + type: object + examples: + - + src: 'https://placehold.co/1200x900@2x.png' + width: 1200 + height: 900 + alt: 'Example image placeholder' + $ref: 'json-schema-definitions://experience_builder.module/image' + text: + title: text + type: string + examples: + - 'Some Text' +slots: { } +js: + original: "const Image = ({\n image,\n text,\n }) => {\n return ( <div> <img {...image} \/> <p>{text}<\/p> <\/div>\n );\n };\n\n\n export default Image;" + compiled: "function _define_property(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj;\n }\n function _object_spread(target) { for(var i = 1; i < arguments.length; i++){ var source = arguments[i] != null ? arguments[i] : {}; var ownKeys = Object.keys(source); if (typeof Object.getOwnPropertySymbols === \"function\") { ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function(sym) { return Object.getOwnPropertyDescriptor(source, sym).enumerable; })); } ownKeys.forEach(function(key) { _define_property(target, key, source[key]); }); } return target;\n }\n import { jsx as _jsx, jsxs as _jsxs } from \"react\/jsx-runtime\";\n const Image = ({ image, text })=>{ return \/*#__PURE__*\/ _jsxs(\"div\", { children: [ \/*#__PURE__*\/ _jsx(\"img\", _object_spread({}, image)), \/*#__PURE__*\/ _jsx(\"p\", { children: text }) ] });\n };\n export default Image;" +css: + original: '' + compiled: '' + diff --git a/tests/modules/xb_test_e2e_code_components/xb_test_e2e_code_components.info.yml b/tests/modules/xb_test_e2e_code_components/xb_test_e2e_code_components.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..21e7b93057d2c96bacf163cb1748464818743b2e --- /dev/null +++ b/tests/modules/xb_test_e2e_code_components/xb_test_e2e_code_components.info.yml @@ -0,0 +1,6 @@ +name: XB test e2e code components +description: 'Code components for e2e testing only - not for use by Kernel tests etc.' +package: Testing +type: module +dependencies: + - experience_builder:experience_builder diff --git a/ui/cypress.config.js b/ui/cypress.config.js index c1c6146fe6651d3e593ca66c67a0e1f436f8345e..af6b707280e46b73b1bc4fb2bdfdf561315e5812 100644 --- a/ui/cypress.config.js +++ b/ui/cypress.config.js @@ -34,7 +34,7 @@ export default defineConfig({ chromeWebSecurity: false, defaultBrowser: process.env.DRUPAL_TEST_DEFAULT_BROWSER || 'chrome', watchForFileChanges: false, - retries: 0, + retries: { openMode: 0, runMode: 3 }, env: { baseUrl: process.env.BASE_URL, dbUrl: process.env.DB_URL, diff --git a/ui/src/components/form/components/Hidden.tsx b/ui/src/components/form/components/Hidden.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a522a1bc089e9047fdf1d4f119ef37fd0bcf94f6 --- /dev/null +++ b/ui/src/components/form/components/Hidden.tsx @@ -0,0 +1,92 @@ +import { a2p } from '@/local_packages/utils'; +import type { Attributes } from '@/types/DrupalAttribute'; +import { useEffect, useRef, useState } from 'react'; +import useMutationObserver from '@/hooks/useMutationObserver'; +import { VALUE_THAT_MEANS_REMOVE } from '@/utils/function-utils'; + +const Hidden = ({ attributes }: { attributes?: Attributes }) => { + const [value, setValue] = useState(attributes?.value || ''); + const ref = useRef<HTMLInputElement | null>(null); + + useEffect(() => { + // Add a mutation observer to the form element this input belongs to. This + // allows us to detect when the input is removed from the DOM, which allows + // us to communicate that removal to the Redux store. + // Because the observer is attached to a parent of the ref, it is added here + // instead of using the `useMutationObserver` hook as placing it in the + // useEffect ensures the full form has been rendered already. + let observer: MutationObserver | null = null; + if (ref.current) { + const name = ref.current.name; + observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + const removedNode = mutation.removedNodes[0] as Element | null; + // Check if the removed element is the input managed by this + // component. + if ( + removedNode && + 'querySelector' in removedNode && + removedNode.querySelector(`[name="${name}"]`) && + ref.current + ) { + ref.current.value = ''; + // Call the onChange listener so the Redux store is updated. + if ( + attributes?.onChange && + typeof attributes.onChange === 'function' + ) { + const event = new Event('change'); + ref.current!.value = VALUE_THAT_MEANS_REMOVE; + Object.defineProperty(event, 'target', { + writable: false, + value: ref.current, + }); + attributes.onChange(event); + } + } + }); + }); + + if ( + ref.current && + 'closest' in ref.current && + ref.current.closest('form') && + observer instanceof MutationObserver + ) { + observer.observe(ref.current.closest('form')!, { + childList: true, + subtree: true, + }); + } + } + return () => { + if (observer instanceof MutationObserver) { + observer.disconnect(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Hidden field values might be updated by AJAX requests and those value + // changes should persist on rerender instead of falling back to the initial + // value in `attributes`. A Mutation Observer is used to monitor value changes + // and keeps track of them in state. + useMutationObserver( + ref, + (mutations) => { + mutations.forEach((record: MutationRecord) => { + if (record?.attributeName === 'value') { + if (record.target instanceof HTMLElement) { + const newValue = record.target.getAttribute(record.attributeName); + setValue(`${newValue}`); + } + } + }); + }, + { attributes: true }, + ); + + return <input ref={ref} {...a2p(attributes || {})} value={value} />; +}; + +export default Hidden; diff --git a/ui/src/components/form/components/drupal/DrupalInput.tsx b/ui/src/components/form/components/drupal/DrupalInput.tsx index 149fd743990ec374b9bd52ca00cb94b597e6228e..5fc1f06365705cc6f6835d4c5e00d8c40b6be8c7 100644 --- a/ui/src/components/form/components/drupal/DrupalInput.tsx +++ b/ui/src/components/form/components/drupal/DrupalInput.tsx @@ -5,6 +5,7 @@ import { a2p } from '@/local_packages/utils.js'; import InputBehaviors from '@/components/form/inputBehaviors'; import TextField from '@/components/form/components/TextField'; import { DrupalRadioItem } from '@/components/form/components/drupal/DrupalRadio'; +import Hidden from '@/components/form/components/Hidden'; import Checkbox from '@/components/form/components/Checkbox'; import type { Attributes } from '@/types/DrupalAttribute'; import TextFieldAutocomplete from '@/components/form/components/TextFieldAutocomplete'; @@ -60,6 +61,9 @@ const DrupalInput = ({ return <DrupalRadioItem attributes={attributes} />; case 'hidden': case 'submit': + if (attributes['data-track-hidden-value']) { + return <Hidden attributes={attributes} />; + } // The a2p() process converts 'value to 'defaultValue', which is typically // what React wants. Explicitly set the value on submit inputs since that // is the text it displays. diff --git a/ui/src/components/form/formUtil.ts b/ui/src/components/form/formUtil.ts index a1f6ce1958d0d13536392ccf540af46bc7b1012d..bc2d025d1bffc9fdd0135f4281a31c96b6e7fac5 100644 --- a/ui/src/components/form/formUtil.ts +++ b/ui/src/components/form/formUtil.ts @@ -300,7 +300,9 @@ export const formStateToObject = ( }); const parsed = qs.parse(params.toString()); if (isParsedQ(parsed.xb_component_props)) { - return parsed.xb_component_props[componentId] as PropsValues; + if (parsed.xb_component_props[componentId]) { + return parsed.xb_component_props[componentId] as PropsValues; + } } return {}; }; diff --git a/ui/src/components/form/inputBehaviors.tsx b/ui/src/components/form/inputBehaviors.tsx index f993cb4cc9bb26ff8f5bbf15b4b4882ddda73a74..25f98340de3a34d4845708e2fff92ac5a054f703 100644 --- a/ui/src/components/form/inputBehaviors.tsx +++ b/ui/src/components/form/inputBehaviors.tsx @@ -20,7 +20,7 @@ import { selectLayout, selectModel, } from '@/features/layout/layoutModelSlice'; -import { parseValue } from '@/utils/function-utils'; +import { parseValue, flaggedForRemoval } from '@/utils/function-utils'; import { debounce } from 'lodash'; import { useGetComponentsQuery } from '@/services/componentAndLayout'; import { findComponentByUuid } from '@/features/layout/layoutUtils'; @@ -186,10 +186,12 @@ const InputBehaviorsCommon = ({ [], ); - // Don't track the value of hidden fields except for form_build_id. + // Don't track the value of hidden fields except for form_build_id or ones + // with the 'data-track-hidden-value' attribute set. if ( ['hidden', 'submit'].includes(elementType as string) && - fieldName !== 'form_build_id' + fieldName !== 'form_build_id' && + !attributes['data-track-hidden-value'] ) { attributes.readOnly = ''; } else if (!attributes['data-drupal-uncontrolled']) { @@ -343,6 +345,25 @@ const InputBehaviorsComponentPropsForm = ( // in onQueryStarted in preview.ts // @see \Drupal\Core\Field\WidgetInterface::massageFormValues() const resolved = { ...selectedModel.resolved, ...values }; + + // Check the object for any values that are flagged for removal. Note that + // removal flagging is not necessary for all prop types. It is used for + // props with complex prop shapes where the empty-indicating value is nested + // with the structure. + Object.keys(values).forEach((prop) => { + if (flaggedForRemoval(values[prop]) && component?.propSources?.[prop]) { + if (!component.propSources[prop]?.required) { + if (isEvaluatedComponentModel(selectedModel)) { + // The source value can also be updated to empty when permitted. + if (!Object.isFrozen(selectedModel.source[prop])) { + selectedModel.source[prop].value = []; + } + } + } + resolved[prop] = []; + } + }); + if (isEvaluatedComponentModel(selectedModel) && component) { patchComponent({ componentInstanceUuid: selectedComponent, diff --git a/ui/src/features/layout/layoutModelSlice.ts b/ui/src/features/layout/layoutModelSlice.ts index dee6177f45c73d54808b52d5aee8ecf9ffcd2ffa..eb4f6ecb7e0e37a2dfef94df19695f0a7b2e87b6 100644 --- a/ui/src/features/layout/layoutModelSlice.ts +++ b/ui/src/features/layout/layoutModelSlice.ts @@ -130,6 +130,7 @@ type AnyValue = string | boolean | [] | number | {} | null; // @see \Drupal\experience_builder\PropSource\PropSource::parse() interface BasePropSource { sourceType: string; + value?: any; } // @see \Drupal\experience_builder\PropSource\DynamicPropSource export interface DynamicPropSource extends BasePropSource { diff --git a/ui/src/hooks/useMutationObserver.ts b/ui/src/hooks/useMutationObserver.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd3e3132f0fe2d5ea8e0e55672f8326609386dca --- /dev/null +++ b/ui/src/hooks/useMutationObserver.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; +import type { MutableRefObject } from 'react'; +interface ObserverOptions { + attributes?: boolean; + characterData?: boolean; + childList?: boolean; + subtree?: boolean; +} + +// This is largely pulled from +// https://www.30secondsofcode.org/react/s/use-mutation-observer/ +const useMutationObserver = ( + ref: MutableRefObject<undefined | null | any>, + callback: MutationCallback, + options: ObserverOptions = { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }, +) => { + useEffect(() => { + if (ref.current) { + const observer = new MutationObserver(callback); + observer.observe(ref.current, options); + return () => observer.disconnect(); + } + }, [callback, options, ref]); +}; + +export default useMutationObserver; diff --git a/ui/src/types/Component.ts b/ui/src/types/Component.ts index 2aa0e8781569f461bda8b1e9d2783a8ade5e029d..7dbfb297d8f333680a0e2dce236b4426651b6481 100644 --- a/ui/src/types/Component.ts +++ b/ui/src/types/Component.ts @@ -35,6 +35,7 @@ interface BaseComponent { css: string; js_header: string; js_footer: string; + propSources: FieldData; } export type libraryTypes = diff --git a/ui/src/utils/function-utils.ts b/ui/src/utils/function-utils.ts index 7102422306ca76a6d56f62748048dfc40b895739..d962582fee61aee8bd614ac8df06f02e01c0665a 100644 --- a/ui/src/utils/function-utils.ts +++ b/ui/src/utils/function-utils.ts @@ -33,6 +33,40 @@ export function isConsecutive(sortedIndexes: number[]): boolean { return true; } +// Some prop shapes do not have a means of representing an empty value, so they +// can't simply have their value replaced when removed. This special string +// flags items for removal that can't be replaced with an empty value. +export const VALUE_THAT_MEANS_REMOVE = '$%^&*JUST_REMOVE'; + +/** + * Checks if a value is flagged for removal. + * + * @param {any} value + * The value to check. + */ +export function flaggedForRemoval(value: any): boolean { + // This will return true if any value within the structure equals + // VALUE_THAT_MEANS_REMOVE. + + if (value === null || value === undefined) { + return false; + } + + if (typeof value === 'string') { + return value === VALUE_THAT_MEANS_REMOVE; + } + + if (Array.isArray(value)) { + return value.some((item) => flaggedForRemoval(item)); + } + + if (typeof value === 'object') { + return Object.values(value).some((item) => flaggedForRemoval(item)); + } + + return false; +} + export function parseValue( value: any, element: HTMLInputElement | HTMLSelectElement, diff --git a/ui/tests/e2e/media-library.cy.js b/ui/tests/e2e/media-library.cy.js index 8e82c8165f738890df4f3e72fcb2aa22a26977ab..410241e9df26fd11866bdae415234d1989a3cc5b 100644 --- a/ui/tests/e2e/media-library.cy.js +++ b/ui/tests/e2e/media-library.cy.js @@ -19,8 +19,14 @@ const iterations = [ }, ]; -const testMediaLibraryInComponentInstanceForm = (cy) => { +const testMediaLibraryInComponentInstanceForm = ( + cy, + entityType = 'xb_page', +) => { cy.get('div[role="dialog"]').should('exist'); + cy.findByLabelText('Select The bones are their money').should( + 'not.be.checked', + ); cy.findByLabelText('Select The bones are their money').check(); cy.get('button:contains("Insert selected")').click(); cy.get('div[role="dialog"]').should('not.exist'); @@ -39,25 +45,49 @@ const testMediaLibraryInComponentInstanceForm = (cy) => { cy.clickComponentInPreview('Image', 1); cy.clickComponentInPreview('Image'); + // The image location in the preview is different depending on the entity + // type. cy.get('[data-testid*="xb-component-form-"]').as('inputForm'); - iterations.forEach((step) => { + cy.intercept('PATCH', '**/xb/api/v0/form/component-instance/**').as('patch'); + + iterations.forEach((step, index) => { + cy.get('@inputForm').recordFormBuildId(); + const priorAlt = + index % 2 === 0 ? iterations[1].expectedAlt : iterations[0].expectedAlt; + const defaultPlaceholder = + entityType === 'xb_page' + ? `[id^="block-"] > img[alt="${priorAlt}"]:first-of-type` + : `img[alt="${priorAlt}"][data-xb-uuid="static-image-udf7d"]`; + cy.log( + `Iteration ${index + 1}: start ${index % 2 === 0 ? iterations[1].expectedAlt : iterations[0].expectedAlt}`, + ); cy.get('[class*="contextualPanel"]').should('exist'); cy.get('div[role="dialog"]').should('not.exist'); - cy.get('@inputForm').recordFormBuildId(); - cy.get('[class*="contextualPanel"]') - .findByLabelText(step.removeText) - .click(); - cy.get('@inputForm').shouldHaveUpdatedFormBuildId(); - cy.get( - '[class*="contextualPanel"] .js-media-library-open-button[data-once="drupal-ajax"]', - ) - .first() - .click(); + const removeIt = `[class*="contextualPanel"] .js-media-library-selection [aria-label="${step.removeText}"][data-once="drupal-ajax"]`; + cy.get(removeIt).click({ force: true }); + + cy.log( + `Confirm removing a required image in step ${index + 1} results in the example appearing in the preview.`, + ); + + // The prior image should still be there because the prop is required. + cy.waitForElementInIframe(defaultPlaceholder); + + // Waiting for the build id does not work - it does not update. + // Waiting for the preview (the last request after clicking remove) does not + // appear to work reliably either. Hence, the fixed wait. Presumably there is + // something that can be waited on, but it is not clear what. + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); + const addIt = `[class*="contextualPanel"] .js-media-library-widget .js-media-library-open-button[data-once="drupal-ajax"]`; + cy.get(addIt).first().click({ force: true }); + cy.get('div[role="dialog"]').should('exist'); cy.findByLabelText(step.selectNewText).check(); - cy.get('button:contains("Insert selected")').click(); + cy.get('button:contains("Insert selected")').realClick({ force: true }); + cy.wait('@patch'); cy.get('div[role="dialog"]').should('not.exist'); - cy.get('@inputForm').shouldHaveUpdatedFormBuildId(); + cy.get('@inputForm').shouldHaveUpdatedFormBuildId({ timeout: 11000 }); cy.get( `[class*="contextualPanel"] input[aria-label="${step.removeAriaLabel}"]`, ).should('exist'); @@ -92,6 +122,7 @@ const testMediaLibraryInEntityForm = (cy, loadOptions = {}, title) => { // Perform media operations. iterations.forEach((step, ix) => { + cy.log(`Iteration ${ix + 1}: start`); cy.findByRole('dialog').should('not.exist'); cy.get('@entityForm').findByRole(step.expectedAlt).should('not.exist'); if (ix > 0) { @@ -104,6 +135,7 @@ const testMediaLibraryInEntityForm = (cy, loadOptions = {}, title) => { cy.wait('@updatePreview'); cy.findByLabelText('Loading Preview').should('not.exist'); cy.get('@entityForm').shouldHaveUpdatedFormBuildId(); + cy.log(`Iteration ${ix + 1}: ${step.removeText} complete`); } cy.get('@entityForm') .findByRole('button', { name: 'Add media', timeout: 10000 }) @@ -129,6 +161,7 @@ const testMediaLibraryInEntityForm = (cy, loadOptions = {}, title) => { .findByRole('button', { name: step.removeAriaLabel }) .should('exist'); cy.get('@entityForm').shouldHaveUpdatedFormBuildId(); + cy.log(`Iteration ${ix + 1}: Adding ${step.expectedAlt} complete`); }); cy.publishAllPendingChanges(title); @@ -148,7 +181,7 @@ const testMediaLibraryInEntityForm = (cy, loadOptions = {}, title) => { describe('Media Library', () => { before(() => { - cy.drupalXbInstall(); + cy.drupalXbInstall(['xb_test_sdc', 'xb_test_e2e_code_components']); }); beforeEach(() => { @@ -192,7 +225,7 @@ describe('Media Library', () => { ) .first() .click(); - testMediaLibraryInComponentInstanceForm(cy); + testMediaLibraryInComponentInstanceForm(cy, 'article'); }); it( @@ -215,7 +248,7 @@ describe('Media Library', () => { ) .first() .click(); - testMediaLibraryInComponentInstanceForm(cy); + testMediaLibraryInComponentInstanceForm(cy, 'xb_page'); }, ); @@ -238,4 +271,165 @@ describe('Media Library', () => { ); }, ); + + it('Can remove an optional image no example and there is no image in the preview', () => { + cy.drupalLogin('xbUser', 'xbUser'); + cy.loadURLandWaitForXBLoaded({ url: 'xb/node/2' }); + cy.openLibraryPanel(); + cy.get( + '[data-xb-component-id="sdc.xb_test_sdc.image-optional-without-example"]', + ).realClick(); + cy.waitForElementNotInIframe('.layout-content img'); + cy.get( + '[class*="contextualPanel"] .js-media-library-open-button[data-once="drupal-ajax"]', + ) + .first() + .click(); + cy.get('div[role="dialog"]').should('exist'); + cy.findByLabelText('Select The bones are their money').check(); + cy.get('button:contains("Insert selected")').click(); + cy.get('div[role="dialog"]').should('not.exist'); + cy.waitForElementInIframe('img[alt="The bones equal dollars"]'); + cy.get('[class*="contextualPanel"]') + .findByLabelText('Remove The bones are their money') + .click(); + + // Confirms the removed optional image prop is not rendered at all, vs the + // example/default value reappearing. + cy.waitForElementNotInIframe('.layout-content img'); + }); + + it('Can remove an optional image with example and there is no image in the preview', () => { + cy.drupalLogin('xbUser', 'xbUser'); + cy.loadURLandWaitForXBLoaded({ url: 'xb/node/2' }); + cy.openLibraryPanel(); + cy.get( + '[data-xb-component-id="sdc.xb_test_sdc.image-optional-with-example"]', + ).realClick(); + cy.waitForElementInIframe('.layout-content img[alt="Boring placeholder"]'); + cy.get( + '[class*="contextualPanel"] .js-media-library-open-button[data-once="drupal-ajax"]', + ) + .first() + .click(); + cy.get('div[role="dialog"]').should('exist'); + cy.findByLabelText('Select The bones are their money').check(); + cy.get('button:contains("Insert selected")').click(); + cy.get('div[role="dialog"]').should('not.exist'); + cy.waitForElementInIframe('img[alt="The bones equal dollars"]'); + cy.get('[class*="contextualPanel"]') + .findByLabelText('Remove The bones are their money') + .click(); + + // Confirms the removed optional image prop is not rendered at all, vs the + // example/default value reappearing. + cy.waitForElementNotInIframe('.layout-content img'); + }); + + it('Can remove an optional code component image with example and there is no image in the preview', () => { + cy.drupalLogin('xbUser', 'xbUser'); + cy.loadURLandWaitForXBLoaded({ url: 'xb/node/2' }); + cy.openLibraryPanel(); + cy.get( + '[data-xb-component-id="js.xb_test_e2e_code_components_optional_image"]', + ).realClick(); + cy.waitForElementInIframe( + '.layout-content img[alt="Example image placeholder"]', + ); + cy.get( + '[class*="contextualPanel"] .js-media-library-open-button[data-once="drupal-ajax"]', + ) + .first() + .click(); + cy.get('div[role="dialog"]').should('exist'); + cy.findByLabelText('Select The bones are their money').check(); + cy.get('button:contains("Insert selected")').click(); + cy.get('div[role="dialog"]').should('not.exist'); + cy.waitForElementInIframe('img[alt="The bones equal dollars"]'); + cy.waitForElementNotInIframe( + '.layout-content img[alt="Example image placeholder"]', + ); + cy.findByLabelText('text').type('{selectall}{del}A new value'); + cy.findByLabelText('text').should('have.value', 'A new value'); + cy.waitForElementContentInIframe('p', 'A new value'); + cy.get('[class*="contextualPanel"]') + .findByLabelText('Remove The bones are their money') + .click(); + + // Confirms the removed optional image prop is not rendered at all, vs the + // example/default value reappearing. + cy.waitForElementNotInIframe('.layout-content img'); + + // Text prop is still intact after image removal. + cy.waitForElementContentInIframe('p', 'A new value'); + // Confirm other props still work. + cy.findByLabelText('text').type( + '{selectall}{del}Further changes to the value', + ); + cy.findByLabelText('text').should( + 'have.value', + 'Further changes to the value', + ); + cy.waitForElementContentInIframe('p', 'Further changes to the value'); + }); + + it.only('Can remove a required code component image with example and there is no image in the preview', () => { + cy.drupalLogin('xbUser', 'xbUser'); + cy.loadURLandWaitForXBLoaded({ url: 'xb/node/2' }); + cy.openLibraryPanel(); + cy.get( + '[data-xb-component-id="js.xb_test_e2e_code_components_req_image"]', + ).realClick(); + cy.waitForElementInIframe( + '.layout-content img[alt="Example image placeholder"]', + ); + cy.get( + '[class*="contextualPanel"] .js-media-library-open-button[data-once="drupal-ajax"]', + ) + .first() + .click(); + cy.get('div[role="dialog"]').should('exist'); + cy.findByLabelText('Select The bones are their money').check(); + cy.get('button:contains("Insert selected")').click(); + cy.get('div[role="dialog"]').should('not.exist'); + cy.waitForElementInIframe('img[alt="The bones equal dollars"]'); + cy.waitForElementNotInIframe( + '.layout-content img[alt="Example image placeholder"]', + ); + cy.findByLabelText('text').type('{selectall}{del}A new value'); + cy.findByLabelText('text').should('have.value', 'A new value'); + cy.waitForElementContentInIframe('p', 'A new value'); + cy.get('[class*="contextualPanel"]') + .findByLabelText('Remove The bones are their money') + .click(); + + // Confirm the widget is now empty. + cy.get('.js-media-library-widget .field-prefix') + .contains('No media items are selected.') + .should('exist'); + cy.get('.js-media-library-widget .description') + .contains('One media item remaining.') + .should('exist'); + + // The previously added image is still in the preview due to it being a + // required prop. + cy.waitForElementInIframe('img[alt="The bones equal dollars"]'); + + // Confirms the example does not return. + cy.waitForElementNotInIframe( + '.layout-content img[alt="Example image placeholder"]', + ); + + // Text prop is still intact after image removal. + cy.waitForElementContentInIframe('p', 'A new value'); + + cy.findByLabelText('text').type( + '{selectall}{del}Further changes to the value', + ); + cy.findByLabelText('text').should( + 'have.value', + 'Further changes to the value', + ); + cy.waitForElementContentInIframe('p', 'Further changes to the value'); + }); }); diff --git a/ui/tests/support/commands.js b/ui/tests/support/commands.js index 1f5e133be41e49348849adc69c46f510dc66b58c..7caa25a8251be3dfb402e6e79fe45580c2d87669 100644 --- a/ui/tests/support/commands.js +++ b/ui/tests/support/commands.js @@ -467,6 +467,40 @@ Cypress.Commands.add( }, ); +/** + * Waits for element matching a selector to not be present in an iframe. + * + * @param {string} selector + * The selector of what to wait on in the iframe. + * @param {string} iframeSelector + * The selector of the iframe to check inside. Defaults to the first preview. + * @param {number|null} customTimeout + * Optional: If the time to wait for the element should differ from the + * Cypress retry default duration. + */ +Cypress.Commands.add( + 'waitForElementNotInIframe', + ( + selector, + iframeSelector = initializedReadyPreviewIframeSelector, + customTimeout, + ) => { + cy.document().then((doc) => { + cy.get(true, { + timeout: customTimeout || Cypress.config('defaultCommandTimeout'), + }).should(() => { + const frameContent = doc + .querySelector(iframeSelector) + ?.contentWindow?.document?.body.querySelector(selector); + expect( + !!frameContent, + `'${selector}' should not be in iframe '${iframeSelector}'`, + ).to.equal(false); + }); + }); + }, +); + Cypress.Commands.add( 'waitForElementContentInIframe', (