Skip to content
Snippets Groups Projects
Commit cc383034 authored by Ben Mullins's avatar Ben Mullins
Browse files

Issue #3462441 by bnjmnm, Wim Leers, jessebaker: Contextual form values need...

Issue #3462441 by bnjmnm, Wim Leers, jessebaker: Contextual form values need to be integrated with Redux: start with single-property field types
parent ddb01ef3
No related branches found
No related tags found
1 merge request!114#3462441: Contextual form -> redux Model
Pipeline #236974 passed with warnings
describe('General Experience Builder', {testIsolation: false}, () => {
before( () => {
cy.drupalXbInstall()
......@@ -9,18 +10,18 @@ describe('General Experience Builder', {testIsolation: false}, () => {
beforeEach(() => {
cy.drupalSession();
//A larger viewport makes it easier to debug in the test runner app.
cy.viewport(2000, 1000);
});
it ('Created a node 1 with type article on install', () => {
cy.drupalRelativeURL('node/1');
cy.get('h1').should(($h1) => {
expect($h1.text()).to.include('XB Needs This')
})
cy.get('h1').should(($h1) => {
expect($h1.text()).to.include('XB Needs This')
})
cy.get('[data-component-id="experience_builder:my-hero"] h1').should(($h1) => {
expect($h1.text()).to.include('hello, world!')
})
cy.get('[data-component-id="experience_builder:my-hero"] button[formaction="https://drupal.org"]').should.exist
cy.get('[data-component-id="experience_builder:my-hero"] button[formaction="https://drupal.org"] ~ button').should.exist
})
......@@ -109,7 +110,7 @@ describe('General Experience Builder', {testIsolation: false}, () => {
expect($outline).to.have.css('position', 'absolute')
expect($outline).to.have.css('top', '0px')
expect($outline).to.have.css('left', '0px')
});
});
// Get the dimensions of the highlighted component in the small preview, so
// it can be compared to its corresponding outline.
......@@ -151,13 +152,15 @@ describe('General Experience Builder', {testIsolation: false}, () => {
cy.get('[role="dialog"][vaul-drawer-direction="right"][data-state="open"]').should('exist')
// The drawer contains a component edit form.
cy.get('[role="dialog"][vaul-drawer-direction="right"][data-state="open"] [data-drupal-selector="component-props-form"].component-props-form').should(($form) => {
expect($form).to.exist
const expectedLabels = ['heading', 'subheading', 'cta1', 'cta1href', 'cta2'];
$form.find('label').each((index, label) => {
expect(label.textContent).to.equal(expectedLabels[index])
cy.get(
'[role="dialog"][vaul-drawer-direction="right"][data-state="open"] [data-drupal-selector="component-props-form"].component-props-form')
.should(($form) => {
expect($form).to.exist
const expectedLabels = ['heading', 'subheading', 'cta1', 'cta1href', 'cta2'];
$form.find('label').each((index, label) => {
expect(label.textContent).to.equal(expectedLabels[index])
})
})
})
cy.get('[data-drupal-selector="edit-xb-component-props-static-static-card1ab-heading-0-value"]')
.should('have.value', 'hello, world!')
......@@ -168,5 +171,79 @@ describe('General Experience Builder', {testIsolation: false}, () => {
.should('have.value', 'https://drupal.org')
.invoke('attr', 'type')
.should('eq', 'url')
const heroSelectors = {
heading: 'h1',
subheading: 'h1 ~ p',
cta1: 'button:first-child',
cta2: 'button:last-child',
}
const heroBefore = {
heading: 'hello, world!',
subheading: '',
cta1: '',
cta2: '',
}
// Confirm the current values of the first "My Hero" component so we can
// be certain these values later change.
cy.testInIframe('[data-xb-type="experience_builder:my-hero"]', (heroes) => {
const hero = heroes[0];
Object.entries(heroSelectors).forEach(([ prop, selector ]) => {
if(heroBefore[prop]) {
expect(hero.querySelector(selector).textContent.onlyVisibleChars()
, `${prop} should be ${heroBefore[prop]}`).to.equal(heroBefore[prop])
} else {
expect(!!hero.querySelector(selector).textContent.onlyVisibleChars()
, `${prop} should be empty`).to.be.false
}
})
expect(hero.querySelector(heroSelectors.cta1).getAttribute('formaction')).to.equal('https://drupal.org')
})
const propEditFormSelectors = {
heading: '[data-drupal-selector="component-props-form"] [data-drupal-selector="edit-xb-component-props-static-static-card1ab-heading-0-value"]',
subheading: '[data-drupal-selector="component-props-form"] [data-drupal-selector="edit-xb-component-props-static-static-card1ab-subheading-0-value"]',
cta1href: '[data-drupal-selector="component-props-form"] [data-drupal-selector="edit-xb-component-props-static-static-card1ab-cta1href-0-value"]',
cta1: '[data-drupal-selector="component-props-form"] [data-drupal-selector="edit-xb-component-props-static-static-card1ab-cta1-0-value"]',
cta2: '[data-drupal-selector="component-props-form"] [data-drupal-selector="edit-xb-component-props-static-static-card1ab-cta2-0-value"]',
}
const newValues = {
heading: 'You parked your car',
subheading: 'Over the sidewalk',
cta1: 'ponytail',
cta2: 'stuck',
cta1href: 'https://hoobastank.com'
}
// Monitor the endpoint that processes changed values in the prop edit form.
cy.intercept('POST', '**/api/preview').as('getPreview')
Object.entries(propEditFormSelectors).forEach(([ prop, selector ]) => {
// Type a new value into a given input.
cy.get(selector).focus().clear().type(newValues[prop])
// Wait for completion of the request triggered by our typing. This
// ensures that the `testInIframe` ~10 lines down is working with an iframe that
// has fully responded to these value changes.
cy.wait('@getPreview')
// Confirm React is properly handling form state by confirming the input
// has the value we typed into it.
cy.get(selector).should('have.value', newValues[prop])
})
// New values were typed into the prop form inputs, now enter the iframe
// and confirm the component reflects these new values.
cy.testInIframe('[data-xb-type="experience_builder:my-hero"]', (heroes) => {
const hero = heroes[0];
Object.entries(heroSelectors).forEach(([ prop, selector ]) => {
expect(
hero.querySelector(selector).textContent.onlyVisibleChars(),
`${prop} (${selector}) should be '${newValues[prop]}'`)
.to.equal(newValues[prop])
})
// Special check for ctaHref as it is an attribute value.
expect(hero.querySelector(heroSelectors.cta1).getAttribute('formaction')).to.equal(newValues.cta1href)
})
})
})
......@@ -20,3 +20,17 @@ import "cypress-axe";
// Alternatively you can use CommonJS syntax:
// require('./commands')
Cypress.on('uncaught:exception', (err, runnable) => {
// This is safe to ignore, and often is with Cypress E2E tests.
// @see https://github.com/w3c/csswg-drafts/issues/6173
// @see https://github.com/w3c/csswg-drafts/issues/6185
if (err.message.includes('ResizeObserver loop limit exceeded') || err.message.includes('ResizeObserver loop completed')) {
return false
}
})
// Remove newlines and excess whitespace from a string.
String.prototype.onlyVisibleChars = function() {
return this.replace(/^(?: |\s)+|(?: |\s)+$/ig,'').trim()
};
.componentFieldForm {
color: white;
}
import {a2p} from '@/local_packages/utils.js';
import {createContext, useState} from 'react';
import type * as React from "react";
import styles from './Form.module.css'
interface FormProps {
attributes: {
......@@ -11,17 +14,25 @@ interface FormProps {
children: string|null
renderChildren: string|any[]|null
}
type NoopDispatch = () => undefined
export const FormStateContext = createContext<object>({});
export const FormDispatchContext = createContext<React.Dispatch<any>|NoopDispatch>(() => {})
const Form = ({attributes = {}, children = '', renderChildren = ''}: FormProps) => {
if (!attributes.style) {
attributes.style = {}
}
attributes.style.color = 'white'
const [formState, setFormState] = useState({formId: attributes['data-drupal-selector'] || ''})
const existingClass = attributes.class || '';
attributes.class = `${existingClass} ${styles.componentFieldForm}`;
return (
<form {...a2p(attributes)}>
{renderChildren}
</form>
)
<FormStateContext.Provider value={formState}>
<FormDispatchContext.Provider value={setFormState}>
<form {...a2p(attributes)}>
{renderChildren}
</form>
</FormDispatchContext.Provider>
</FormStateContext.Provider>
);
}
export default Form;
import { a2p } from '@/local_packages/utils.js';
import type { ChangeEvent } from "react";
import { useState} from "react";
import type * as React from 'react';
import inputBehaviors from "./inputBehaviors";
const Input = (props: React.ComponentProps<any>) => {
const { attributes = {}, renderChildren = '' } = props;
const [value, setValue] = useState(attributes.value || '');
const onChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
}
return (
<>
{attributes?.type === 'submit' && <input {...a2p(attributes)} onChange={onChangeHandler} />}
{attributes?.type !== 'submit' && <input {...a2p(attributes)} onChange={onChangeHandler} value={value} />}
{attributes?.type !== 'submit' && <input {...a2p(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. */}
{attributes?.type === 'submit' && <input {...a2p(attributes)} value={attributes.value || ''} />}
{renderChildren}
</>
);
};
export default Input;
export default inputBehaviors(Input)
import {a2p} from '@/local_packages/utils.js';
import inputBehaviors from "./inputBehaviors";
import {useState} from "react";
import type {ChangeEvent} from "react";
import type * as React from "react";
const Textarea = (props: React.ComponentProps<any>) => {
const Textarea: React.FC<any> = (props: any) => {
const {value = '', attributes = {}, wrapperAttributes = {}}: {value: string, attributes: any, wrapperAttributes: any} = props;
const [theValue, setTheValue] = useState(value || attributes.value || '');
const onChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
setTheValue(e.target.value);
}
return (<div{ ...a2p(wrapperAttributes) }>
<textarea { ...a2p(attributes) } onChange={onChangeHandler} defaultValue={theValue}>
......@@ -18,4 +18,4 @@ const Textarea = (props: React.ComponentProps<any>) => {
</div>)
}
export default Textarea;
export default inputBehaviors(Textarea)
import {useState, useContext, useEffect, useCallback} from 'react';
import type * as React from "react";
import {FormDispatchContext} from "./Form";
import {selectSelectedComponent} from "@/features/ui/uiSlice";
import {useAppDispatch, useAppSelector} from "@/app/hooks";
import {selectModel, updateNodeModel} from "@/features/layout/layoutModelSlice";
import {debounce} from "lodash";
// Wraps all form elements to provide common functionality and subscribe to the
// parent form's context.
const InputBehaviors = (OriginalInput: React.FC) => {
function WrappedInput(properties: React.ComponentProps<any>): React.ReactElement {
const dispatch = useAppDispatch();
const selectedComponent = useAppSelector(selectSelectedComponent) || 'noop';
const model = useAppSelector(selectModel);
const selectedModel = model[selectedComponent];
const {attributes, ...passProps} = properties;
const [inputValue, setInputValue] = useState(attributes.value || '');
const setFormState = useContext(FormDispatchContext);
const formStateToStore = (newFormState: object) => {
// Get only the keys that correspond to SDC props.
const keys = Object.keys(newFormState).filter((key) => key.includes('xb_component_props['));
// Create an object with the prop names -> current value in form.
const propsValues = keys.reduce((newObject:object, key: string) => {
// Extract the prop name from the drupal-selector.
// @todo: THIS CURRENTLY ONLY WORKS WITH PROPS WITH A SINGLE `value`
// PROPERTY!
// Expand the supported prop shapes in https://www.drupal.org/i/3463842.
const keyJustProp: string = key
.replace(`xb_component_props[${selectedComponent}][`, '')
.replace(/\].*$/, '')
newObject[keyJustProp as keyof object] = newFormState[key as keyof object]
return newObject;
}, {});
dispatch(updateNodeModel({
uuid: selectedComponent,
model: {...selectedModel, ...propsValues}
}))
}
// Include the input's default value in the form state on init.
useEffect(() => {
if (attributes.name && setFormState) {
setFormState((prior: object) => ({ ...prior, [attributes.name]: inputValue}))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Use debounce to prevent excessive repaints of the layout.
const debounceStoreUpdate = debounce(formStateToStore, 400)
// Register the debounced store function as a callback so debouncing is
// preserved between renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
const storeUpdateCallback = useCallback((value: object) => debounceStoreUpdate(value), []);
if (['hidden', 'submit'].includes(attributes.type)) {
attributes.readOnly = '';
} else if (!attributes['data-drupal-uncontrolled']) {
// If the input is not explicitly set as uncontrolled, its state should
// be managed by React.
attributes.value = inputValue;
attributes.onChange = (e:React.ChangeEvent) => {
const target = e.target as HTMLInputElement;
// Update the value of the input - which belongs to just this instance
// of inputBehaviors.
setInputValue(target.value)
// In addition, update the Context-stored Form State, which is aware
// of all form values plus additional metadata.
if (setFormState) {
setFormState((prior: object) => {
const newState = {...prior, [target.name]: target.value }
storeUpdateCallback(newState)
return newState
})
}
}
}
// React objects to inputs with the value attribute set if there are no
// event handlers added via on* attributes.
const hasListener = Object.keys(attributes).some((key) => /^on[A-Z]/.test(key))
// The value attribute can remain for hidden and submit inputs, but
// otherwise dispose of `value`.
if (!hasListener && !['hidden', 'submit'].includes(attributes.type)) {
delete attributes.value
}
return (
<>
<OriginalInput
{...passProps}
attributes={attributes}
/>
</>
);
}
return WrappedInput;
}
export default InputBehaviors;
......@@ -7,6 +7,12 @@
border: 1px solid #ccc;
}
.progress {
position: absolute !important;
width: 100%;
background: transparent;
}
.loadingOverlay {
position: absolute;
top: 0;
......
import styles from './Preview.module.css';
import type React from 'react';
import { useRef, useEffect, useCallback } from 'react';
import { useRef, useEffect, useCallback, useState } from 'react';
import {
addNewComponentToLayout,
moveNode,
......@@ -9,8 +9,7 @@ import {
sortNode,
} from '@/features/layout/layoutModelSlice';
import { useAppDispatch, useAppSelector } from '@/app/hooks';
import { Card, Spinner } from '@radix-ui/themes';
import clsx from 'clsx';
import { Card, Progress } from '@radix-ui/themes';
import {
selectDragging,
selectHoveredComponent,
......@@ -38,6 +37,7 @@ interface ViewportProps {
const Viewport: React.FC<ViewportProps> = (props) => {
const { height, width, frameSrcDoc, isLoading, previewId } = props;
const [isReloading, setIsReloading] = useState(false)
const iframeRef = useRef<HTMLIFrameElement>(null);
const layout = useAppSelector(selectLayout);
const model = useAppSelector(selectModel);
......@@ -130,6 +130,13 @@ const Viewport: React.FC<ViewportProps> = (props) => {
componentsRef.current = components;
}, [components]);
useEffect(() => {
if (iframeRef.current) {
setIsReloading(true)
iframeRef.current.srcdoc = frameSrcDoc;
}
}, [frameSrcDoc]);
useEffect(() => {
// Takes each sortable item (component) and adds a dragstart event listener. This is so that we can implement a custom
// dragImage (the floating representation of what you are dragging that follows your cursor).
......@@ -216,6 +223,8 @@ const Viewport: React.FC<ViewportProps> = (props) => {
initComponentClick(item);
});
});
setIsReloading(false)
};
}, [
dispatch,
......@@ -235,20 +244,13 @@ const Viewport: React.FC<ViewportProps> = (props) => {
{width}px x {height}px
</Card>
<div className={styles.previewContainer}>
{(isLoading || isReloading) && <><Progress aria-label='Loading Preview' className={styles.progress} duration='1s' /></>}
<iframe
ref={iframeRef}
className={styles.preview}
data-xb-preview={previewId}
title="Preview"
srcDoc={frameSrcDoc}
></iframe>
<div
className={clsx(styles.loadingOverlay, {
[styles.show]: isLoading,
})}
>
<Spinner loading={isLoading} size="3" />
</div>
{!isDragging && (
<>
<Outline
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment