Stencil unit test: access deeply nested Shadow DOM components - ionic-framework

I have a Stencil.js component library with some nested elements that I'm unit testing
I'm using the newSpecPage helper functionality provided by Stencil:
const page = await newSpecPage({
components: [CustomFileInput, CustomPanel],
html: `
<custom-file-input multiple>
</custom-file-input>
`
});
The CustomPanel component is nested within the CustomFileInput component. To query elements within CustomPanel I have to step down through Shadow DOM elements before being able to run a querySelector() command successfully
// page.root is the CustomFileInput HTML Element
const customPanelElement = page.root.shadowRoot.querySelector('custom-panel');
const queriedElemnt = customPanelElement.shadowRoot.querySelector('panel-child')
I'm wondering is there a more concise way to get a reference to the CustomPanel or panel-child elements rather than querying down through the components? In the above case there's just 2 components, but there could be more of course

I presume you are using shadow:true so you have to use page.root.shadowRoot.querySelector('custom-panel')
What you can do is, you can assign reference inside beforeEach block
const customPanelElement;
const queriedElemnt;
const page;
beforeEach(async () => {
page = await newSpecPage({
components: [CustomFileInput, CustomPanel],
html: `
<custom-file-input multiple>
</custom-file-input>
`,
supportsShadowDom: true
});
//Create reference here so that it is available inside every test case
customPanelElement = page.root.shadowRoot.querySelector('custom-panel');
queriedElemnt = customPanelElement.shadowRoot.querySelector('panel-child')
});

Related

Is it possible in vue3 to access the root DOM element in a child component slot? I am trying to use a 3rd party library (sortablejs) in vue3

In vue2 I could use this.$el
export default {
render() {
return this.$slots.default[0]
},
mounted() {
Sortable.create(this.$el, {});
})
}
If, in vue3 I try to use this.$slots.default()[0] I can't see how to target the element.
If I use a template ref, I can get the div, but not the contained slot.
The closest question / answer I have found is here Vue 3 Composition API - How to get the component element ($el) on which component is mounted
but this also seems to give the div, but not the slot $el.
This was extremely powerful in vue2 because sortable could be passed a ul, or a div, or another constructed sortable vue component in a slot, and work without the element having to be defined in the child component and I can't work out how to replicate this in vue3.
I originally came across this in a screen cast by Adam Wathan: "Building a Sortable Component with Vue.js", but this was vue2.
I've come up with the following (perhaps there are better out there)
Use template ref:
<template>
<div ref="root">
<slot></slot>
</div>
</template>
Then in the script:
import { ref, onMounted } from 'vue'
export default {
setup() {
const root = ref(null)
onMounted(() => {
// the DOM element will be assigned to the ref after initial render
// console.log(root.value.children[0]) // this is your $el
let el = root.value.children[0]
Sortable.create(el, {})
})
return {
root
}
}
}

Need proper way to render jsx component inside Leaflet popup when using geojson pointToLayer function

Hi is there any way to pass jsx component to bindPopup function so I can push redux commands on button click?
pointToLayer={(
geoJsonPoint: Feature<Point, DeviceProperties>,
latlng,
) => {
const marker = L.marker(latlng);
marker.setIcon(
markerIcon({ variant: geoJsonPoint.properties.relation }),
);
const sddds = (
<div className="font-quicksand">
<h2>{geoJsonPoint.properties.id}</h2>
<h2>{geoJsonPoint.properties.name}</h2>
<p>{geoJsonPoint.properties.description}</p>
<p>{geoJsonPoint.properties.ownerId}</p>
<a
onClick={() => {
dispatch(setDevice(geoJsonPoint.properties));
}}
>
Open device details
</a>
</div>
);
marker.bindPopup(renderToString(sddds));
return marker;
}}
I know I can use react leaflet component but that way I cant pass props into every marker options (I mean marker as layer).
So this has been discussed a bit. There is an issue in the react-leaflet repo discussing this, whose conclusion is to simply use vanilla JS within the bindPopup method to create your popup. I don't like this solution at all, especially when you're trying to use very react oriented event handlers (like react-redux actions) from within a popup.
The question React-leaflet geojson onEachFeature popup with custom react component was asked, which you may have read, as you use react's renderToString method in your code. But as you've probably discovered, this does not maintain any interactivity or JS that your JSX may include. The answerer there came up with the idea of using a modal instead of a popup, but that doesn't exactly answer your question or truly using JSX in a popup based off of a point-layer geojson.
Ultimately, you will not be able to return JSX from the pointToLayer function that is interactive. I think this would be a nice feature that react-leaflet doesn't currently implement. Within the closure of the pointToLayer function, there's no good way to directly write fully functional JSX.
I played with this for a bit, trying to harness pointToLayer and save the feature of each iteration to state, and then render a Marker with Popup from that, but it got me thinking - why bother? Just ditch the GeoJSON component altogether and render your Markers and Popups directly from the JSON object. Like this:
{myGeoJson.features.map((feature, index) => {
return (
<Marker
key={index}
position={L.latLng(feature.geometry.coordinates.reverse())}
>
<Popup>
<button
onClick={() => { yourReduxAction() }}
>
Click meeee
</button>
</Popup>
</Marker>
);
})}
Working sandbox
In this way, you need to work a little harder by manually transforming your GeoJSON into Markers with Popups, but not nearly as hard as trying to bend over backwards by going from JSX (<GeoJSON />) to vanilla JS (pointToLayer) back to JSX (<Popup />).
These are two solutions I have come to and want to share if someone is having same problem.
My problem with using leaflet-react Popup component is that it will not pass geojson properties to marker layer when I just map over geojson object because react-leaflet Marker does not have api for feature like geojson layer does and I need to access those properties via marker layers in other parts of map.
Solution 1:
Use ReactDOM.render() inside pointToLayer method, react will show warning about pure functions but it will work. You just shoud not render imported component because it will complain about store and redux provider, instead paste component code inside render. If you want to avoid warnings create another function / hook and render code inside its useEffect() to container (div or something).
Here is example:
const popup = L.popup();
const marker = L.marker(latlng);
const container = L.DomUtil.create('div');
render(
<div>
<h2>{props.id}</h2>
<h2>{props.name}</h2>
<p>{props.description}</p>
<p>{props.ownerId}</p>
<a onClick={() => dispatch(setDevice(geoJsonPoint.properties))}></a>
</div>,
container,
);
popup.setContent(container);
marker.bindPopup(popup);
return marker;
With custom hook / function:
const useRenderPopup = (props) => {
const container = L.DomUtil('div');
const dispatch = useAppDispatch()
useEffect(() => {
render(
<div>
<h2>{props.id}</h2>
<h2>{props.name}</h2>
<p>{props.description}</p>
<p>{props.ownerId}</p>
<a onClick={() => dispatch(setDevice(props.geoJsonPoint.properties))}></a>
</div>,
container,
);
},[])
return container;
}
and just call this function like popup.setContent(useRenderPopup(someprop)), this way there will be no warning.
Solution 2:
Render everything static with renderToString() and other stuff that need to trigger redux update attach event listeners.
const popup = L.popup();
const marker = L.marker(latlng);
const link = L.DomUtil.create('a');
const container = L.DomUtil.create('div');
const content = <DeviceSummary {...geoJsonPoint.properties} />;
marker.setIcon(markerIcon({ variant: geoJsonPoint.properties.relation }));
link.addEventListener('click', () =>
dispatch(setDevice(geoJsonPoint.properties)),
);
link.innerHTML = 'Show device details';
container.innerHTML = renderToString(content);
container.appendChild(link);
popup.setContent(container);
marker.bindPopup(popup);
return marker;
Here DeviceSummary component is static so I render it as a string and later append link with redux callback added as event listener to it.
(both solutions except custom function example goes into pointToLatyer method inside geoJSON layer)

react-testing-library - How do you test for the existence of text if it is wrapped contained in other child html tags?

Given the example html:
const fragment = (<div>Hello <span>world</span>. How are you <b>doing</b>?</div>)
Is there a way to ignore all the child html tags with react-testing-library?
like so (psuedo-code)
expect(fragment).toHaveTextContent(/hello world. how are you doing/)
First solution:
You can use #testing-library/jest-dom this library provides a set of custom jest matchers that you can use to extend jest and react-testing-library. Here is the link:
https://github.com/testing-library/jest-dom#tocontainelement
<span data-testid="ancestor"><span data-testid="descendant"></span></span>
const ancestor = getByTestId('ancestor')
const descendant = getByTestId('descendant')
const nonExistantElement = getByTestId('does-not-exist')
expect(ancestor).toContainElement(descendant)
expect(descendant).not.toContainElement(ancestor)
expect(ancestor).not.toContainElement(nonExistantElement)
Second solution:
import { render, within } from '#testing-library/react'
const { getByLabelText } = render(<MyComponent />)
const signinModal = getByLabelText('Sign In')
within(signinModal).getByPlaceholderText('Username')

Extract function to standalone custom React component in react-leaflet

My primary goal is to call fitBounds whenever a FeatureGroup is rendered in react-leaflet on initial load.
This renders correctly -
<Map>
<LayersControl>
{getLayers(groups)}
</LayersControl>
</Map>
function getLayers(featureGroups: MyFeatureGroup[]){
const showOnLoad = true;
return featureGroups.map((group: MyFeatureGroup) => {
const groupRef = createRef<FeatureGroup>();
const { id, name, } = group;
return (
<LayersControl.Overlay checked={showOnLoad} key={id} name={name}>
<FeatureGroup ref={groupRef}>
<Layer {...group} />
</FeatureGroup>
</LayersControl.Overlay>
);
});
}
However, because it is using a function instead of React component, I don't have access to using React hooks.
The alternative that I tried does not work, even though it is the same code wrapped in a React component -
...same as above...
return featureGroups.map((group: MyFeatureGroup) => (
<ControlledGroup {...group} showOnLoad={showOnLoad} /> ///----- > ADDED THIS HERE
));
const ControlledGroup: React.FC<ControlledGroupProps> = (props) => {
const groupRef = createRef<FeatureGroup>();
const { map } = useLeaflet();
/// -----> map is correctly defined here - injecting to all of the layers (LayersControl, FeatureGroup) does not solve the problem
const { showOnLoad, ...group } = props;
useEffect(() => fitBounds(map, groupRef)); ///-----> Primary Goal of what I am trying to accomplish
return (
<LayersControl.Overlay
checked={showOnLoad}
key={group.id}
name={name}
>
<FeatureGroup ref={groupRef}>
<Layer map={map} {...group} />
</FeatureGroup>
</LayersControl.Overlay>
);
};
I am a bit stumped, since this is the same code. The getLayers function returns a ReactNode in both cases. However, when moving to a standalone ControlledGroup component, it throws an error on render -
addOverlay is not a function
I tried creating a custom class component for react-leaflet, but the difficulty that I ran into there is that createLeafletElement returns a Leaflet.Element, whereas I am simply looking to return a ReactNode. That is, all of these are valid react-leaflet components already.
My questions - why does one work and the other does not? What is the correct/recommended way to convert this function to a renderable stand-alone React component?
Further, if there is an alternative pattern to calling fitBounds, that would be helpful as well.
Any insight would be appreciated.
Since the Layers share an inheritance with Layers.Overlay, the solution to the render error is to keep the Layers together and move the feature group to a standalone component.
This works as expected and allows me to call useEffect on the groupRef -
function getLayers(groups: MyFeatureGroup[]){
return featureGroups.map((group: MyFeatureGroup) => {
const { id, name, } = group;
return (
///---> Keep the Overlay in the function here and extract just the FeatureGroup out
<LayersControl.Overlay checked={showOnLoad} key={id} name={name}>
<ControlledGroup {...group}></ControlledGroup>
</LayersControl.Overlay>
);
}

Is there a way/workaround to have the slot principle in hyperHTML without using Shadow DOM?

I like the simplicity of hyperHtml and lit-html that use 'Tagged Template Literals' to only update the 'variable parts' of the template. Simple javascript and no need for virtual DOM code and the recommended immutable state.
I would like to try using custom elements with hyperHtml as simple as possible
with support of the <slot/> principle in the templates, but without Shadow DOM. If I understand it right, slots are only possible with Shadow DOM?
Is there a way or workaround to have the <slot/> principle in hyperHTML without using Shadow DOM?
<my-popup>
<h1>Title</h1>
<my-button>Close<my-button>
</my-popup>
Although there are benefits, some reasons I prefer not to use Shadow DOM:
I want to see if I can convert my existing SPA: all required CSS styling lives now in SASS files and is compiled to 1 CSS file. Using global CSS inside Shadow DOM components is not easily possible and I prefer not to unravel the SASS (now)
Shadow DOM has some performance cost
I don't want the large Shadow DOM polyfill to have slots (webcomponents-lite.js: 84KB - unminified)
Let me start describing what are slots and what problem these solve.
Just Parked Data
Having slots in your layout is the HTML attempt to let you park some data within the layout, and address it later on through JavaScript.
You don't even need Shadow DOM to use slots, you just need a template with named slots that will put values in place.
<user-data>
<img src="..." slot="avatar">
<span slot="nick-name">...</span>
<span slot="full-name">...</span>
</user-data>
Can you spot the difference between that component and the following JavaScript ?
const userData = {
avatar: '...',
nickName: '...',
fullName: '...'
};
In other words, with a function like the following one we can already convert slots into useful data addressed by properties.
function slotsAsData(parent) {
const data = {};
parent.querySelectorAll('[slot]').forEach(el => {
// convert 'nick-name' into 'nickName' for easy JS access
// set the *DOM node* as data property value
data[el.getAttribute('slot').replace(
/-(\w)/g,
($0, $1) => $1.toUpperCase())
] = el; // <- this is a DOM node, not a string ;-)
});
return data;
}
Slots as hyperHTML interpolations
Now that we have a way to address slots, all we need is a way to place these inside our layout.
Theoretically, we don't need Custom Elements to make it possible.
document.querySelectorAll('user-data').forEach(el => {
// retrieve slots as data
const data = slotsAsData(el);
// place data within a more complex template
hyperHTML.bind(el)`
<div class="user">
<div class="avatar">
${data.avatar}
</div>
${data.nickName}
${data.fullName}
</div>`;
});
However, if we'd like to use Shadow DOM to keep styles and node safe from undesired page / 3rd parts pollution, we can do it as shown in this Code Pen example based on Custom Elements.
As you can see, the only needed API is the attachShadow one and there is a super lightweight polyfill for just that that weights 1.6K min-zipped.
Last, but not least, you could use slots inside hyperHTML template literals and let the browser do the transformation, but that would need heavier polyfills and I would not recommend it in production, specially when there are better and lighter alternatives as shown in here.
I hope this answer helped you.
I have a similar approach, i created a base element (from HyperElement) that check the children elements inside a custom element in the constructor, if the element doesn't have a slot attribute im just sending them to default slot
import hyperHTML from 'hyperhtml/esm';
class HbsBase extends HyperElement {
constructor(self) {
self = super(self);
self._checkSlots();
}
_checkSlots() {
const slots = this.children;
this.slots = {
default: []
};
if (slots.length > 0) {
[...slots].map((slot) => {
const to = slot.getAttribute ? slot.getAttribute('slot') : null;
if (!to) {
this.slots.default.push(slot);
} else {
this.slots[to] = slot;
}
})
}
}
}
custom element, im using a custom rollup plugin to load the templates
import template from './customElement.hyper.html';
class CustomElement extends HbsBase {
render() {
template(this.html, this, hyperHTML);
}
}
Then on the template customElement.hyper.html
<div>
${model.slots.body}
</div>
Using the element
<custom-element>
<div slot="body">
<div class="row">
<div class="col-sm-6">
<label for="" class="">Name</label>
<p>
${model.firstName} ${model.middleInitial} ${model.lastName}
</p>
</div>
</div>
...
</div>
</custom-element>
Slots without shadow dom are supported by multiple utilities and frameworks.
Stencil enables using without shadow DOM enabled. slotted-element gives support without framework.