I have a component from 3rd party which emits "onCellEdit" event and passes a cell element as parameter.
In my event handler I want to automatically select the whole text in the input element that is generated inside of this cell.
The problem I'm having is that when my handler is triggered the input element is not yet loaded.
(cellElement as HTMLTableCellElement).querySelector('input') returns nothing since the 3rd party component needs some time I guess.
My solution now looks like this:
selectTextOnEdit(cell: HTMLTableCellElement) {
const repeater = (element: HTMLTableCellElement) => {
const inputElement = element.querySelector('input');
if (inputElement) {
inputElement.select();
} else {
setTimeout(() => { repeater(element); }, 50);
}
};
repeater(cell);
}
this function then triggers the repeater function which goes around until the input element is found. I know I'm missing some kind of a check in case the input element is never generated.. but it's not important for this question.
I highly dislike this solution and I'm sure there are better ones.
Update:
After some research I found out about "MutationObserver".
Here is my new solution:
selectTextOnEdit(cell: HTMLTableCellElement) {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.addedNodes && mutation.addedNodes.length > 0) {
const inputElement = cell.querySelector('input');
if (inputElement) {
inputElement.select();
observer.disconnect();
}
}
});
});
observer.observe(cell, {childList: true});
}
For these kind of scenarios, I like to use a utility function of waitUntil.
import { interval } from 'rxjs';
import { take } from 'rxjs/operators';
...
export const waitUntil = async (untilTruthy: Function): Promise<boolean> => {
while (!untilTruthy()) {
await interval(25).pipe(
take(1),
).toPromise();
}
return Promise.resolve(true);
}
Then in your function, it would be:
async selectTextOnEdit(cell: HTMLTableCellElement) {
await waitUntil(() => !!cell.querySelector('input'));
const inputElement = element.querySelector('input');
inputElement.select();
}
This is the same thing but slightly cleaner in my opinion. Why is it an issue that the input was never created, shouldn't always be created if the callback of selectTextOnEdit is called?
After some research I found out about "MutationObserver".
Here is my new solution:
selectTextOnEdit(cell: HTMLTableCellElement) {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.addedNodes && mutation.addedNodes.length > 0) {
const inputElement = cell.querySelector('input');
if (inputElement) {
inputElement.select();
observer.disconnect();
}
}
});
});
observer.observe(cell, {childList: true});
}
Related
I implemented a kind of logic using MutationObserver and Promise to find a DOM element for the reason of DOM changes.
const waitForElement = async (
selector
) => {
return new Promise((resolve) => {
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
console.log('found element=', element);
if (element) {
observer.disconnect();
resolve(element);
}
});
observer.observe(document, {
childList: true,
subtree: true,
});
});
});
waitForElement('button[data-test="node"]').then(el => {
console.log('button element', el);
});
I think the page what I am on now has several iframes and has lazy load.
But I noticed that this works for only the first time specifically whenever I refresh a page.
If I check the log found element= inside the MutationObserver, then I can see the found element and logged that element several times, but it doesn't resolve that element as a result.
I am expecting that the waitForElement function should find the button element whenever the DOM changes every time.
As mentioned in the comments, a promise only resolves once. You can create a new promise each time it resolves, to wait for the next one. With your code structure, you can await the promise in a loop.
const container = document.getElementById("container");
const waitForElement = async(
selector
) => {
return new Promise((resolve) => {
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
console.log('found element=', element.nodeName);
if (element) {
observer.disconnect();
resolve(element);
}
});
observer.observe(container, {
childList: true
});
});
}
(async() => {
while (true) {
let el = await waitForElement('button[data-test="node"]');
console.log('button element', el.nodeName);
}
})();
setInterval(() => {
container.innerHTML = '<button data-test="node">X</button>';
}, 2000);
<div id="container">
</div>
I have a click that calls the method:
public clickEvent() {
this.createIframe().then((iframe) => { // Return iframe or create if is not before needed inside
// Async hard logic here
})
}
Problem is when user clicks a lot of times clickEvent() it fires promise and then fires a hard logic inside.
How to avoid click until logic inside is not finished?
Or disable to call logic inside if it is done?
If you're using Angular, I think you can convert the click event into an observable and then use the variety of operators such as exhaustMap to achieve this.
import { exhaustMap, fromEvent } from 'rxjs';
....
#ViewChild('btnId') btnElementRef!: ElementRef<HTMLButtonElement>;
ngAfterViewInit(): void {
fromEvent(this.btnElementRef.nativeElement, 'click')
.pipe(
exhaustMap(() => this.createIframe())
)
.subscribe((iframe) => {
// hard coded async logic here
});
}
);
This will ignore sub-sequent click until the Promise resolve first.
Further more, if you want to disable the button and display somekind of loading indicator, you can also add a variable to track that inside the stream using tap
fromEvent(this.btnElementRef.nativeElement, 'click')
.pipe(
tap(() => isProcessing = true),
exhaustMap(() => this.createIframe())
)
.subscribe((iframe) => {
isProcessing = false;
// hard coded async logic here
});
Make createIframe cache its Promise (like as an instance property), and return that first if it exists, instead of starting another. For example:
// example function that creates the Promise
const createPromise = () => {
console.log('creating Promise');
return new Promise(resolve => setTimeout(resolve, 3000));
}
class SomeClass {
createIframe() {
if (this.iframePromise) return this.iframePromise;
this.iframePromise = createPromise();
return this.iframePromise;
}
clickEvent() {
this.createIframe().then((iframe) => {
console.log('clickEvent has received the Promise and is now running more code');
})
}
}
const s = new SomeClass();
button.onclick = () => s.clickEvent();
<button id="button">click to call clickEvent</button>
If you also want to prevent // Async hard logic here from running multiple times after multiple clicks, assign something to the instance inside clickEvent instead.
// example function that creates the Promise
const createPromise = () => {
console.log('creating Promise');
return new Promise(resolve => setTimeout(resolve, 3000));
}
class SomeClass {
createIframe() {
return createPromise();
}
clickEvent() {
if (this.hasClicked) return;
this.hasClicked = true;
this.createIframe().then((iframe) => {
console.log('clickEvent has received the Promise and is now running more code');
})
}
}
const s = new SomeClass();
button.onclick = () => s.clickEvent();
<button id="button">click to call clickEvent</button>
I have a POST API call that I make on a button click. We have one large state object that gets sent as body for a POST call. This state object keeps getting updated based on different user interactions on the page.
function QuotePreview(props) {
const [quoteDetails, setQuoteDetails] = useState({});
const [loadingCreateQuote, setLoadingCreateQuote] = useState(false);
useEffect(() => {
if(apiResponse?.content?.quotePreview?.quoteDetails) {
setQuoteDetails(apiResponse?.content?.quotePreview?.quoteDetails);
}
}, [apiResponse]);
const onGridUpdate = (data) => {
let subTotal = data.reduce((subTotal, {extendedPrice}) => subTotal + extendedPrice, 0);
subTotal = Math.round((subTotal + Number.EPSILON) * 100) / 100
setQuoteDetails((previousQuoteDetails) => ({
...previousQuoteDetails,
subTotal: subTotal,
Currency: currencySymbol,
items: data,
}));
};
const createQuote = async () => {
try {
setLoadingCreateQuote(true);
const result = await usPost(componentProp.quickQuoteEndpoint, quoteDetails);
if (result.data?.content) {
/** TODO: next steps with quoteId & confirmationId */
console.log(result.data.content);
}
return result.data;
} catch( error ) {
return error;
} finally {
setLoadingCreateQuote(false);
}
};
const handleQuickQuote = useCallback(createQuote, [quoteDetails, loadingCreateQuote]);
const handleQuickQuoteWithoutDeals = (e) => {
e.preventDefault();
// remove deal if present
if (quoteDetails.hasOwnProperty("deal")) {
delete quoteDetails.deal;
}
handleQuickQuote();
}
const generalInfoChange = (generalInformation) =>{
setQuoteDetails((previousQuoteDetails) => (
{
...previousQuoteDetails,
tier: generalInformation.tier,
}
));
}
const endUserInfoChange = (endUserlInformation) =>{
setQuoteDetails((previousQuoteDetails) => (
{
...previousQuoteDetails,
endUser: endUserlInformation,
}
));
}
return (
<div className="cmp-quote-preview">
{/* child components [handleQuickQuote will be passed down] */}
</div>
);
}
when the handleQuickQuoteWithoutDeals function gets called, I am deleting a key from the object. But I would like to immediately call the API with the updated object. I am deleting the deal key directly here, but if I do it in an immutable way, the following API call is not considering the updated object but the previous one.
The only way I found around this was to introduce a new state and update it on click and then make use of the useEffect hook to track this state to make the API call when it changes. With this approach, it works in a weird way where it keeps calling the API on initial load as well and other weird behavior.
Is there a cleaner way to do this?
It's not clear how any children would call the handleQuickQuote callback, but if you are needing to close over in callback scope a "copy" of the quoteDetails details then I suggest the following small refactor to allow this parent component to use the raw createQuote function while children receive a memoized callback with the current quoteDetails enclosed.
Consume quoteDetails as an argument:
const createQuote = async (quoteDetails) => {
try {
setLoadingCreateQuote(true);
const result = await usPost(componentProp.quickQuoteEndpoint, quoteDetails);
if (result.data?.content) {
/** TODO: next steps with quoteId & confirmationId */
console.log(result.data.content);
}
return result.data;
} catch( error ) {
return error;
} finally {
setLoadingCreateQuote(false);
}
};
Memoize an "anonymous" callback that passes in the quoteDetails value:
const handleQuickQuote = useCallback(
() => createQuote(quoteDetails),
[quoteDetails]
);
Create a shallow copy of quoteDetails, delete the property, and call createQuote:
const handleQuickQuoteWithoutDeals = (e) => {
e.preventDefault();
const quoteDetailsCopy = { ...quoteDetails };
// remove deal if present
if (quoteDetailsCopy.hasOwnProperty("deal")) {
delete quoteDetailsCopy.deal;
}
createQuote(quoteDetailsCopy);
}
I've read all of the relevant questions on this topic, and I realize this will probably be marked as a duplicate, but I simply cannot for the life of me figure out how to get this working.
I have this simple function that lazily loads elements:
export default function lazyLoad(targets, onIntersection) {
const observer = new IntersectionObserver((entries, self) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
onIntersection(entry.target);
self.unobserve(entry.target);
}
});
});
document.querySelectorAll(targets).forEach((target) => observer.observe(target));
return observer;
}
Example usage:
lazyLoad('.lazy-img', (img) => {
const pictureElement = img.parentElement;
const source = pictureElement.querySelector('.lazy-source');
source.srcset = source.getAttribute('data-srcset');
img.src = img.getAttribute('data-src');
});
Now, I'm trying to test the lazyLoad function using jest, but I obviously need to mock IntersectionObserver since it's a browser API, not a native JavaScript one.
The following works for testing the observe method:
let observe;
let unobserve;
beforeEach(() => {
observe = jest.fn();
unobserve = jest.fn();
window.IntersectionObserver = jest.fn(() => ({
observe,
unobserve,
}));
});
describe('lazyLoad utility', () => {
it('calls observe on each target', () => {
for (let i = 0; i < 3; i++) {
const target = document.createElement('img');
target.className = 'lazy-img';
document.body.appendChild(target);
}
lazyLoad(
'.lazy-img',
jest.fn(() => {})
);
expect(observe).toHaveBeenCalledTimes(3);
});
});
But I also want to test the .isIntersecting logic, where the callback fires... Except I don't know how to do that. How can I test intersections with jest?
Mocking stuff is so easy when you pass it as an argument:
export default function lazyLoad(targets, onIntersection, observerClass = IntersectionObserver) {
const observer = new observerClass(...)
...
}
// test file
let entries = [];
const observeFn = jest.fn();
const unobserveFn = jest.fn()
class MockObserver {
constructor(fn) {
fn(entries,this);
}
observe() { observeFn() }
unobserve() { unobserveFn() }
}
test('...',() => {
// set `entries` to be something so you can mock it
entries = ...something
lazyLoad('something',jest.fn(),MockObserver);
});
Another option is to use mockObserver mentioned above and mock in window.
window.IntersetionObserver = mockObserver
And you don't necessary need to pass observer in component props.
The important point to test if entries isIntesecting is to mock IntersectionObserver as class like mentioned above.
I am implementing an outside click hooks by class name
const useClickOutside = (className, f) => {
function handleClickOutside(event) {
if(event.which !== 1) return
const nodes = document.querySelectorAll(className)
console.log(nodes.length) // display the right length
console.log(nodes) // display the right elements
nodes.some((node) => { // falls
let outside = !node.contains(event.target)
if(outside) { f(); }
return outside
})
}
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
}
i call the hooks like that
useClickOutside(".foo",
() => {
// some code ...
},
);
i got this error TypeError: nodes.some is not a function even if just before the some function i got everything working on the nodes array !!
SOLUTION
thanks to #enapupe answer we can use also ES6
const nodes = [ ...document.querySelectorAll(className) ]
The Document method querySelectorAll() returns a static (not live) NodeList representing a list of the document's elements that match the specified group of selectors.
https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll
The NodeList type is not compatible with an array type, which has some.
You can do Array.from(selector) in order to use regular array prototypes on top of it.
const useClickOutside = (className, f) => {
function handleClickOutside(event) {
if (event.which !== 1) return
const nodes = Array.from(document.querySelectorAll(className))
nodes.some((node) => {
let outside = !node.contains(event.target)
if (outside) {
f()
}
return outside
})
}
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
}