StarWars API: Create buttons to move between planet pages - javascript

I'm using the very famous StarWars API and I should extrapolate the data from the first page of the planets (and so far I have no problems). Sure, I had to do some research to figure out how to do it, but I finally succeeded. But now there is another problem.
I would have to create buttons that allow the user to go to the second page of the planets and get data from that page. And here I am stuck, because I don't know how to do it, because then, using the Next Button, the user must be able to go to the third,, fourth, fifth and sixth page, and then returning back to the first page of planets.
So I looked at the URL and thought we could bind a variable to it that is itself bound to a value provided by the buttons. In this way, if the user clicks on the button the chosen value should be given to the variable located after the URL. To do this I used the ++ value and --value patterns, which do what they should: increase and decrease the value.
However, this is not transmitted to the URL and the new fetch process does not take place to get the info from the pages.
But I did something wrong and I don't know what.
Could you please help me? ..
This is the problematic code:
value = 1
const prevButton = document.createElement("button");
prevButton.innerText = 'Prev Page'
prevButton.setAttribute("id", "prev-button")
document.body.append(prevButton);
prevButton.style.cssText = "background-color: violet; width: 150px; height: 45px;
transform: translateY(-725%); margin-left: -10px;"
prevButton.addEventListener('click', () => {
return --value
})
const nextButton = document.createElement("button");
nextButton.innerText = 'Next Page'
nextButton.setAttribute("id", "next-button")
document.body.append(nextButton);
nextButton.style.cssText = "background-color: violet; width: 150px; height: 45px; transform: translateY(-825%); margin-left: 90.5%;"
nextButton.addEventListener('click', () => {
return ++value
})
const response = await fetch ('https://swapi.dev/api/planets/?page=' + value);
const planetsData = await response.json().then(planetsData => {
let results = planetsData.results;
results.forEach(planet => {
const wrapper = document.createElement("wrapper")
square.append(wrapper);
wrapper.style.cssText = 'background-color: red; width: 200px; height: 250px; margin: auto; margin-top: 1.5%; margin-left: 50px; display: flex; flex-wrap: nowrap"; flex-direction: row;'
wrapper.innerHTML = '<div><h1>Name: ' + planet.name + '</h1>' +
'<h3><p>Climate: ' + planet.climate + '</p></h3>' +
'<p>Population: ' + planet.population +
'<p>Terrain: ' + planet.terrain + '</p></div>';
});
})

Here's an example that embellishes on my comment. I've hardcoded the buttons here for convenience but the key points are:
getting that fetch process in its own function so that it can be called whenever a button is clicked
Using the next and previous properties of the returned data to guide which page should be displayed next.
// Cache the elements
const planets = document.querySelector('.planets');
const buttons = document.querySelector('.buttons');
const previous = document.querySelector('[data-id="previous"]');
const next = document.querySelector('[data-id="next"]');
// Add one listener to the button container
buttons.addEventListener('click', handleClick);
// Initialise the base endpoint, and `data`
const base = 'https://swapi.dev/api/planets/?page=1';
let data = {};
// Fetch the first page
fetchData();
// When a button is clicked grab its
// data id (which will be either "previous" or "next"
// and use that to fetch the next page
function handleClick(e) {
if (e.target.matches('.button')) {
const { id } = e.target.dataset;
fetchData(id);
}
}
// Pass in the value of the data attribute as `id`
// If the data has a previous/next property
// use its value to get the previous/next page, otherwise
// use the base endpoint
// Then create some HTML from the data and add it to
// the page, finally updating the disabled state of
// each button
async function fetchData(id) {
const endpoint = data[id] || base;
const res = await fetch(endpoint);
data = await res.json();
const html = createHTML(data.results);
planets.innerHTML = html;
previous.disabled = !data.previous ? true : false;
next.disabled = !data.next ? true : false;
}
// Pass in the new data, `map` over it, and return
// a string of HTML
function createHTML(data) {
return data
.map(planet => {
return `
<section class="planet">
<h4>${planet.name}</h4>
<p>Population: ${planet.population}</p>
<p>Terrain: ${planet.terrain}</p>
</section>
`;
})
.join('');
}
.planet { border: 1px solid #efefef; padding: 0.4em; }
.button:hover { cursor: pointer; }
<section class="buttons">
<button data-id="previous" class="button">Prev</button>
<button data-id="next" class="button">Next</button>
</section>
<section class="planets"></section>
Additional documentation
querySelector
map
join
Destructuring assignment
matches
Template/string literals
Data attributes

Related

How to reset value after button click?

I'm creating a simple app in which user provides a number and square is generated with the given number. The problem is that when the user provides the input the first time, it works as intended, but when the user edits the value and clicks the button, the value adds with the existing value and it displays the square of the sums. I want to reset the input after the button click, but haven't got any clue how to do. Any solutions
The html code looks like this
<div class="intro">
<h1>Select no of squares to be made</h1>
<small>This experience is better with the values between 400 and 600</small>
<input type="number" id="squaresInput">
<button class="show">Go</button>
</div>
And js file is
const userInput = document.getElementById('squaresInput')
const btnInput = document.querySelector('.show')
btnInput.addEventListener('click', () => getUserInput())
let squaresNum
function getUserInput(){
let squaresNum = userInput.value
for(let i = 0; i < squaresNum; i++){
const square = document.createElement('div')
square.classList.add('square')
square.addEventListener('mouseover', () => setColor(square))
square.addEventListener('mouseout', () => removeColor(square))
container.appendChild(square)
}
}
This should work for you :
userInput.value = ''
I'd start moving
const userInput = document.getElementById('squaresInput')
into "getUserInput()" function.
Then removing the "let squaresNum" outside the function, it is useless.
Gianluca
You can just add userInput.value = ""; at the end of function, which will clear out any existing value in the input.
const userInput = document.getElementById('squaresInput')
const btnInput = document.querySelector('.show')
btnInput.addEventListener('click', () => getUserInput())
let squaresNum;
function getUserInput(){
let squaresNum = userInput.value
for(let i = 0; i < squaresNum; i++){
const square = document.createElement('div')
square.classList.add('square')
square.addEventListener('mouseover', () => setColor(square))
square.addEventListener('mouseout', () => removeColor(square))
container.appendChild(square)
}
userInput.value = "";
}
<div class="intro">
<h1>Select no of squares to be made</h1>
<small>This experience is better with the values between 400 and 600</small>
<input type="number" id="squaresInput">
<button class="show">Go</button>
<div class="container"></div>
</div>
You need something like this before adding more squares.
container.innerHTML = ""
at the beginning of
function getUserInput () {....
In this way you will eliminate the squares previously created before creating the new ones.
Take advantage of using <form>.
Change <div class='intro'> to <form id='intro'>
Whatever .container is change it to <fieldset id='container'> or name='container'. If you don't want the border, in CSS .container {border:0;}
Now you just register the 'submit' event to the <form>
When any <button>, <input type='submit'>, or <button type='submit'> is clicked or a form control has focus and user keys Enter/Return the 'submit' event is triggered.
event.preventDefault() is to stop the <form> from trying to send data to a server.
this.reset(); will clear the <input>
Unless setColor() and removeColor() do more than just change div.square color, it's better just to use CSS. .square {border-color: red; background: red}
Using a <form> allows you to use the HTMLFormControlsCollection and HTML Forms API. It's terse and specific which allows more control and less coding.
// Reference a `<form>`
const f = document.forms['id'] /*OR*/ document.forms.id // name can be used as well
// Reference all form controls within <form>
const io = f.elements;
// Reference a form control within <form>
const btn = io['id'] /*OR*/ io.id // name can be used as well
Use createDocumentFragment(); because 400 to 600 nodes to render is really too much.
Also added a <button> to clear out the squares whenever the user wishes to.
Added 'mouseover/out' event handler. It's registered to the parent element only and not to each .square. This and the 'submit' event are possible because of Event Bubbling and how Event Delegation leverages it.
In the event handler hoverGrid() values, expressions, statements, and functions (ex. addColor() and removeColor()) can be placed within the appropriate case of the switch().
...
case 'mouseover':
addColor();
break;
case 'mouseout':
removeColor();
break;
...
const I = document.forms.intro;
const io = I.elements;
const clr = io.clr;
const box = io.box;
I.onsubmit = createGrid;
clr.onclick = clearGrid;
box.onmouseover = hoverGrid;
box.onmouseout = hoverGrid;
function createGrid(event) {
event.preventDefault();
let cnt = parseInt(io.qty.value);
let frag = document.createDocumentFragment();
for (let i = 0; i < cnt; i++) {
let square = document.createElement('div');
square.className = 'square';
square.dataset.index = i + 1;
frag.appendChild(square);
}
box.appendChild(frag);
this.reset();
console.clear();
console.log(box.childElementCount);
};
function clearGrid(event) {
this.previousElementSibling.innerHTML = '';
};
function hoverGrid(event) {
let E = event.type;
const sqr = event.target;
if (sqr.matches('.square')) {
switch (E) {
case 'mouseover':
console.log(sqr.dataset.index);
break;
case 'mouseout':
console.clear();
break;
default:
break;
}
}
};
:root {
font-size: 16px;
}
body {
font-family: Consolas;
line-height: 1;
overflow: auto;
}
input,
button {
display: inline-block;
font: inherit
}
small {
display: block;
margin-bottom: 4px;
}
button {
width: 4ch;
padding: 2px 5px;
cursor: pointer;
}
#box {
display: flex;
flex-flow: row wrap;
max-width: 96vw;
height: max-content;
}
.square {
width: 1ch;
height: 1ch;
border: 1px solid black
}
.square:hover {
border-color: red;
background: red;
}
#clr {
width: 9ch;
float: right;
}
/* SO Console Display - Rightside Column */
.as-console-wrapper {
width: 20% !important;
font-variant: normal;
font-weight: bold;
color: red;
}
.as-console-row.as-console-row::after {
content: '';
padding: 0;
margin: 0;
border: 0;
width: 0;
}
<form id="intro">
<fieldset>
<legend>Select no. of squares to be made</legend>
<small>This experience is better with the values between 400 and 600</small>
<input type="number" id="qty" min='0' max='1000'>
<button>GO</button>
</fieldset>
<fieldset id='box'></fieldset>
<button id='clr' type='button'>CLEAR</button>
</form>

How to add concatenate button value into an api

So I'm making a holiday finder "app" and I want to have buttons where when someone clicks the name of their country it inputs the country's value into the api string.
What I did was loop over all the buttons and save the target value to a variable which I then concatenated into the api. The thing is when I look in the console at the api fetched, where the country code is supposed to be it says "undefined".
I'm a bit confused on why so if you find the solution please explain it.
let countrySelect = document.getElementById('country-select');
let holidayName = document.getElementById('holiday-name');
let holidayDesc = document.getElementById('holiday-desc');
let holidayDate = document.getElementById('holiday-date');
let holidayType = document.getElementById('holiday-type');
let info = document.querySelector('.cell');
let buttonValue;
// get button values
const button = document.querySelectorAll("button").forEach(
button => button.addEventListener('click', function(e) {
buttonValue = e.target.value;
console.log(buttonValue)
})
);
// api url
const api = `https://calendarific.com/api/v2/holidays?&api_key=<api key>&country=${buttonValue}&year=2020`;
// When the button is clicked fetch results
countrySelect.addEventListener('click', function() {
fetch(api)
.then(res => res.json())
.then(data => {
var apiResponse = data.response;
console.log(apiResponse);
}, networkError => {
alert(networkError)
})
})
You need to define / redefine your api variable within the countrySelect event listener.
At the moment it is being defined before any buttons are clicked, so buttonValue is undefined. So even if your buttonValue changes in response to buttons being clicked, the api variable stays how it was, ie. with country=undefined.
let countrySelect = document.getElementById('country-select');
let buttonValue;
const button = document.querySelectorAll("button").forEach(
button => button.addEventListener('click', function(e) {
buttonValue = e.target.value;
console.log(buttonValue);
})
);
// When the button is clicked fetch results
countrySelect.addEventListener('click', function() {
const api = `https://calendarific.com/api/v2/holidays?&api_key=<api key>&country=${buttonValue}&year=2020`;
console.log(api);
});
#country-select {
border: 1px solid green;
color: green;
display: inline-block;
cursor: pointer;
}
<button value='uk'>
UK
</button>
<button value ='us'>
US
</button>
<div id='country-select'>
Select Country
</div>

iterating across multiple buttons using key value pairs array to add event listeners with loops

I am working on a simple calculator project
I am attempting to automate adding event listeners to the various numeric buttons (1-9).
The event listeners will listen to click events on the buttons leading to a change in the display section of the HTML (class = .display)
key value pairs being b1-b9 containing the various corresponding values.
I have come up with the below FOR EACH loop. For some reason it causes all numerical buttons to apply the number 9; which i believe is the cause of the for each loop.
I am unsure how to quite fix it. I have also come up with an alternative FOR loop that leads to another problem. pairs[Properties[i]].toString() returns undefined.
interestingly if i swap pairs[Properties[i]].toString() out to just i then the SAME issue occurs
Help really appreciated and thank you..
const pairs = {
b1: 1,
b2: 2,
b3: 3,
b4: 4,
b5: 5,
b6: 6,
b7: 7,
b8: 8,
b9: 9,
};
var Properties = Object.keys(pairs);
function loadButtons () {
for (var item in pairs) {
//for each key property in pairs
console.log(item);
let targetCell = document.querySelector("." + item.toString())
// querySelector only targets the FIRST element found
// in this case only 1 for that name
console.log(targetCell);
targetCell.addEventListener('click', () => {
// you want it to manipulate the display as and when clicked
var currentDisplay = document.querySelector(".display").innerHTML.toString();
newDisplay = currentDisplay + pairs[item].toString();
document.querySelector(".display").innerHTML = newDisplay;
})
// console.log(pairs[item]);
// // pairs[item] retrieves the value to that "key"
}
};
function alternative() {
var i;
var Properties = Object.keys(pairs);
for (i = 0; i < Properties.length; i++) {
let targetCell = document.querySelector("." + Properties[i].toString())
// querySelector only targets the FIRST element found
// in this case only 1 for that name
console.log(targetCell);
targetCell.addEventListener('click', () => {
// you want it to manipulate the display as and when clicked
var currentDisplay = document.querySelector(".display").innerHTML.toString();
newDisplay = currentDisplay + pairs[Properties[i]].toString();
document.querySelector(".display").innerHTML = newDisplay;
})
};
};
Expected should be clicking of 1 to add a string "1" to the current string of the calculator, so on .
function onClick(item, pairs) {
return () => {
// you want it to manipulate the display as and when clicked
var currentDisplay = document.querySelector(".display").innerHTML.toString();
var newDisplay = currentDisplay + pairs[item].toString();
document.querySelector(".display").innerHTML = newDisplay;
}
}
var Properties = Object.keys(pairs);
function loadButtons () {
for (var item in pairs) {
//for each key property in pairs
console.log(item);
let targetCell = document.querySelector("." + item.toString())
// querySelector only targets the FIRST element found
// in this case only 1 for that name
console.log(targetCell);
targetCell.addEventListener('click', onClick(item, pairs))
// console.log(pairs[item]);
// // pairs[item] retrieves the value to that "key"
}
};
You should use event delegation instead of looping over and attaching events to every button. Here's an example:
var keyboard = document.getElementById('keyboard');
var display = document.getElementById('display');
keyboard.addEventListener('click', function(ev) {
var val = ev.target.id;
if (ev.target.localName === 'button') {
display.innerText += val;
}
});
.calculator {
width: 300px;
background: whitesmoke;
}
#display {
height: 50px;
background: #d2d2d2;
border: 1px solid #9c9c9c;
margin: 10px auto 10px;
font-size: 20px;
line-height: 50px;
padding: 0 10px;
}
#keyboard {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
button {
font-size: 20px;
padding: 20px;
margin: 5px;
cursor: pointer;
}
<div class="calculator">
<div id="display" contenteditable="true" >
</div>
<div id="keyboard">
<button id="0">0</button>
<button id="1">1</button>
<button id="2">2</button>
<button id="3">3</button>
<button id="4">4</button>
<button id="5">5</button>
<button id="6">6</button>
<button id="7">7</button>
<button id="8">8</button>
<button id="9">9</button>
</div>
</div>
i will do it this way but that not the only one i guess and there could be better ways.
const BUTTONS_NAMESVALUES={
//-- sound awful when a loop can do that!
bt0:0,bt1:1,bt2:2,bt3:3,bt4:4,bt5:5,bt6:6,bt7:7,bt8:8,bt9:9
}
function checkButtonValue(bt){
if(BUTTONS_NAMESVALUES[bt.id] !=null && BUTTONS_NAMESVALUES[bt.id] !='undefined'){
return bt.innerHTML;
}return;
}
//a button may look like that
<button id="bt1">1</button>
//-- with listener:
document.getElementById('bt1').addEventListener('click', function(e){
let chk=checkButtonValue(this);
if(chk!=null && chk!='undefined' && chk!=''){
document.getElementById('calculatorScreen').innerHTML=''+document.getElementById('calculatorScreen').innerHTML+chk;
}
});
I hope that help. I just replace the class name '.display' who can easily be a source of error(because it's the name of a CSS property and anything is display in HTML+ using an id better in that case because it's a unique element and can't be mistaken, classes aren't) and is not very accurate(as i write a correct constante name who has some meaning instead of pairs who means really nothing ^^).
Neither i 've automated the code into a loop but that's the easy part who is ever in your script.

Why won't JavaScript function create an HTML element?

I have a method, which is meant to create divs within another div... However it won't work...
Here is the method:
populateSquares(){
let relatedTagToPopulation = document.getElementById("questionp");
let whatTextIs = relatedTagToPopulation.textContent;
for (let u=0;u<this.stemQuestions.length;u++){
if (this.stemQuestions[u]==whatTextIs){
var populateSquaresPertinentInt = u;
}
}
for (let i=0;i<this.stemAnswers.length;i++){
if (i==populateSquaresPertinentInt){
let numberOfSquaresToAdd = this.stemAnswers[i].length;
for (let j=0;j<numberOfSquaresToAdd;j++){
let elToAdd = document.createElement("<div id='ans"+j+"' class='lans'></div>");
let elToAddInto = document.getElementById("answeri");
elToAddInto.appendChild(elToAdd);
}
}
}
}
It gives out this error...
Uncaught DOMException: Failed to execute 'createElement' on 'Document': The tag name provided ('<div id='ans0' class='lans'></div>') is not a valid name.
If you are using JavaScript, you should follow the document: https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement, and here: CreateElement with id?
let elToAdd = document.createElement('div')
// then call `elToAdd.xxxx` to add attributes or do other operation on the element
elToAdd.setAttribute("id", "ans" + i);
// ... more
If you are using jQuery, you can use:
let elToAdd = jQuery("<div id='ans"+j+"' class='lans'></div>")
Three Ways to Create Tags
The following examples all do the same thing:✱
Create an <article> tag with the class: .post and add it to the <main id="main"> tag.
✱ There's an exception see #2 .innerHTML
document.createElement(tagName)
The only parameter is a tagName (ex. "DIV", "SPAN", "IFRAME", etc.). Once created it needs to be added to the DOM:
const post = document.createElement("ARTICLE");
post.className = "post";
document.getElementById('main').appendChild(post);
This is an old yet stable method but it takes two lines to create one barebones tag. More code is necessary to assign attributes and content.
.innerHTML += htmlString
This property will parse a tag(s) out of a given String within the targeted tag. If an = operator is used all content of the targeted tag is overwritten with the htmlString. If += operator is used the htmlString will be appended to the content within the targeted tag.
document.querySelector('main').innerHTML += `<article class='post'></article>`;
This pattern is simple and versatile. In one line multiple tags can be created with attributes and content. It's limited to either overwriting content: = or appending to content: +=.
✱Edit: Kaiido has informed me that .innerHTML will replace everything so if you are concerned about event bindings or references don't use it. See comments below.
.insertAdjacentHTML(position, htmlString)
This is .innerHTML on steroids. It will insert before/after/inside/outside a given htmlString of a targeted tag. The first parameter is one of four strings that determine the position of insertion relative to the targeted tag:
"beforebegin" <div id="target"> "afterbegin" text content "beforeend" </div> "afterend"
The second parameter is the htmlSting to be inserted.
document.getElementsByTagName('MAIN')[0].insertAdjacentHTML('afterbegin', `
<article class='post'></article>
`);
I couldn't follow your code but it's supposed to be a method? So the demo has an object called populate and there's a factory function called documentSection() that creates objects and inherits the method .createSection() from populate.
Demo
let text = ['post 1', 'post 2', 'post 3'];
let archives = ['document 1', 'document 2', 'document 3'];
const populate = content => ({
createSections: () => {
let idx = 0;
const target = document.querySelector("main");
/*
Pattern 1 - document.createElement(tagName)
*/
const section = document.createElement('SECTION');
section.id = content.title;
target.appendChild(section);
/*
Pattern 2 - .innerHTML += htmlString
*/
section.innerHTML += `<h2>${content.title}</h2>`;
for (let text of content.text) {
idx++;
/*
Pattern 3 - .insertAdjacentHTML(position, htmlString)
*/
section.insertAdjacentHTML('beforeend', `<article id="${content.title}-${idx}" class="t">${text}</article>`);
}
}
});
const documentSection = (title, text) => {
let content = {
title: title,
text: text
};
return Object.assign(content, populate(content));
};
const post = documentSection('Post', text);
const archive = documentSection('Archive', archives);
post.createSections();
archive.createSections();
main {
display: table;
border: 3px ridge grey;
padding: 2px 10px 10px;
}
h1 {
font: 900 small-caps 1.5rem/1 Tahoma;
margin-bottom: 8px
}
h2 {
font: 700 small-caps 1.2rem/1 Tahoma;
margin-bottom: 8px
}
section {
border: 2px solid #000;
padding: 2px 8px 8px;
}
article {
font: 400 1rem/1.25 Arial;
}
.t::before {
content: attr(id)': ';
font-weight: 700;
}
<main>
<h1>Documents</h1>
</main>

Apply/Replay MutationRecord changes (MutationObserver API)

I've attached a MutationObserver to a DOM and am monitoring for changes.
I get the notification and receive a MutationRecord object containing a description of what was changed.
Is there a supported/standard/easy way to apply the changes in MutationRecord again? In other words, use the MutationRecord object to "replay" changes to the DOM?
Any help appreciated!
I couldn't help myself, after noticing that this was asked 6 years ago in 2016, so I made a "DomRecorder Lite". This program records three types on DOM mutation events that can happen inside a target element, in this case some div. You can play around with the tool and try to:
Add elements
Change text of a selected element
Remove selected element
Replay selected action
Replay all actions
There might be a bug or two, but I think that the Proof of Concept is there either way.
I also made a small video demonstrating how the program works (when I press edit-button, a prompt asking for a text appears, but that isn't recorded): https://www.veed.io/view/7325e363-ac5c-4c9b-8138-cd990b253372
We use two custom classes called MutationEvent and DOMRecorder:
MutationEvent
MutationEvent is responsible for storing information about an observer mutation event, such as what the event was (TEXTCHANGED | INSERT | REMOVE), DOM element data related to the event and just generic data, in case we need to attach some extra data to an event.
MutationEvent is also responsible for replaying itself depending on what the event type is. I made this PoC to support three types of events as written above.
The INSERT case is somewhat interesting because of the user's ability to replay one event (doesn't clear the recorded wrapper element) or to replay all events (clears the recorded wrapper element first).
When replaying only one INSERT event, we can't directly insert the element saved to the MutationEvent, because it would have the same id in DOM as the element from which this event originates in the first place, so we create this sort of "actor element", which is a clone of the original element (and its child content), but with a different id.
If we are replaying all the events instead, the recorded wrapper element is cleared first, which means we don't have to worry about this issue.
case 'INSERT':
elem = document.querySelector(`#${$targetid}`);
if(elem) {
let actorElement = null;
if(clean) {
actorElement = this.element.cloneNode(true);
actorElement.id = 'd-' + Math.random().toString(16).slice(2, 8);
}
else {
actorElement = this.element;
}
elem.append(actorElement);
success = true;
}
break;
DOMRecorder
DOMRecorder is kind of the meat and potatoes of everything. Its main resposibility is to use MutationObserver to observe changes in the DOM of the recorded element and to create MutationEvents from those changes. Other resposibilities include replaying a single or every MutationEvent it has stored. In this PoC, the DOMRecorder is also responsible for updating some of the select inputs on the page, which is probably not 100% legitimate way to separate concerns, but that's not relevant here.
Again, this is just a proof of concept how replaying of MutationEvents could be implemented, so there are only a limited number of supported events. This PoC also doesn't take a position regarding deep DOM subtree modifications, but after coding this, I am not too scared to think of it. see edit
Is there a supported/standard/easy way
Probably not. Can't think of many uses for such standard at least. There are some libraries out there that record user's interactions on a webpage, but those don't need to record/replay actual DOM modifications, only the interactions that could result in DOM modifications.
Should also keep in mind that, in general, it's a lot easier to implement a system that watches for changes and emits some kind of change events telling you that "this just changed", than it is to implement a system that is able to re-create those changes. The former doesn't really need to care about anything other than changes happening anywhere in any form, but with the latter, there are a lot of considerations when it comes to re-creation.
To list some:
Should you re-create something that should be unique? Is it really a replay, if the replayed changes are not exactly the same as the recorded ones?
What if the "replay" doesn't start from the same state as the "recording" started?
Should you also record what the replay does?
How do you handle local relations between events?
How do you handle global relations between events and the DOM?
If I make an element, change it's text, remove the element and want to replay the text change event again, what should happen?
What should happen when a parent of deeply nested element gets removed or changed in some way that it can no longer hold the nested element?
If we decided that deletion of a parent of deeply nested element should remove the whole element branch, should that be an event of itself, or perhaps even multiple removal events for each obsolete child on the branch?
How to keep track of CSS changes, after trying this out, it is not as simple as it seems
EDIT
So I added events for subtree insertion and removal. This means that one can now add child elements inside other elements. Functions that worked for the top-level elements should work for any child element as well, or child-of-a-child, or child-of-a-child-of-a-child, ...
This was a bit tricky to implement and the biggest issue here was keeping tracking of parent element ids. See, when an element is created, some id will be generated for it. To insert an element inside of another existing element, we obviously need to know its id. Now this seems like a no problem at first, the user selected parent element has some id, so we just get it and insert the element inside.
Everything seems to work, but we run into a problem when replaying the events and the subtree insertion just won't do anything. The reason for this was that when the program replays the events, specifically the SUBINSERT event, it uses a wrong id for the parent element. This is because the replay function clears the recorded wrapper before starting the replay and therefore all the elements created during replay will get new ids, so the SUBINSERT will not know what the parent element id is during the replay.
To fix this, I added a second, "static internal id" for each event:
constructor(recorderInstance, event, element, data)
{
this.static_replay_id = getId();
...
}
This id will not change from recording to replay, so when we want to insert an element as a child element to some parent element, we do the following event-wise:
Get the id of the mutation.target, which is the id of the parent element
Assume that since this parent element exists, there must also exist an event for it already
Get the static_replay_id of the event by the mutation.target id
Add the static_replay_id as subtree_target for this SUBINSERT event
getStaticIdForMutationTarget:
getStaticIdForMutationTarget(targetid)
{
return this.events.find((evt) => evt.element.id === targetid).static_replay_id;
}
And when we replay the SUBINSERT event, we get the current DOM id of the parent element by the saved static_replay_id:
elem = document.querySelector(`#${$targetid}div#${this.recorderInstance.getEventTargetByStaticReplayId(this.data.subtree_target)}`);
getEventTargetByStaticReplayId:
getEventTargetByStaticReplayId(replay_id)
{
let targetid = null;
for(let i = 0; i < this.events.length; i++) {
let evt = this.events[i];
if(!"static_replay_id" in evt.data)
continue;
if(evt.static_replay_id === replay_id) {
targetid = evt.element.id;
break;
}
}
return targetid;
}
EDIT 2
Added support for applying and replaying inline CSS style events and changed long event name strings, such as "SUBINSERT" or "SUBREMOVE" -> "SUBINS", "SUBREM", so that the UI works better.
Also fixed a couple of bugs regarding mutation event targets and subtree elements. Had to also re-implement how changing element text node text works. The most interesting takeaway here is that for some reason, if we try to change element's textnode's text with elem.childNodes[0].value = text, MutationObserver will not catch that - I guess it doesn't count as any kind of "change in the DOM tree".
// Can't use elem.textContent / innerText / innerHTML...
// might accidentally overwrite element's subtree
// Can't do it like this, because MutationObserver can't see this (why??)
// elem.childNodes[0].value = text;
// This is the way
elem.replaceChild(document.createTextNode(text), elem.childNodes[0]);
At this point I've noticed that a "cleaning" process is required when replaying events:
// Used to clean "dirtied" elements before replaying.
// Consider the event flow:
// 1. Create element X
// 2. Change X CSS or subinsert to X
// 3. Remove X
// 4. Replay
// Without cleanup, during replay at step 1, the created element
// will already have "later" CSS changes and subinserts applied to it ahead of time
// because of ""the way we do things"".
// This fixes that issue
__clean()
{
// OLD CLEANUP METHOD
/*while (this.element.lastElementChild) {
this.element.lastElementChild.style = "";
this.element.removeChild(this.element.lastElementChild);
}
this.element.style = "";
this.element.innerText = this.element.id;*/
// NEW CLEANUP METHOD (don't have to care what changed)
// Reset event's element
this.element = this.originalElement.cloneNode(true);
// Must remember to set the original id back
// Or else it will be the clone's id
this.element.id = this.originalId;
}
As explained in the code snippet comments, there are certain "event flows", which could lead to wrong replay results, such as some events/changes being applied to elements ahead of time so to speak.
To fix this, each event performs an internal "cleaning process" before actually replaying itself. This might not be needed, were the implementation a bit different, but for now it has to be done.
When it comes to the question "What exactly needs to be cleaned?", I am honestly not sure. As far as I know, we seem to have to clean everything that any event could possibly modify on an element.
OLD WAY
Currently that means:
Clearing element DOM subtree of children
Resetting element inline styles
Resetting element's innerText to what it originally is (it's id)
NEW WAY
I figured we can get around caring what needs to be reset in the cleaning process by just creating a clone of each even't original related element and then replacing the event's element with the original unchanged clone before replaying:
class MutationEvent
{
constructor(recorderInstance, event, element, data)
{
this.originalId = this.element.id;
this.originalElement = this.element.cloneNode(true);
// Important to change the id to "hide" the clone
this.originalElement.id = getId("o-");
...
}
}
Anyways, here's the code:
const $recordable = document.querySelector('#recordable');
const $eventlist = document.querySelector('#event-list');
const $elementlist = document.querySelector('#element-list');
function getId(prefix = "d-") {
return prefix + Math.random().toString(16).slice(2, 8);
}
// MutationEvent class
class MutationEvent
{
constructor(recorderInstance, event, element, data)
{
this.recorderInstance = recorderInstance;
this.event = event;
this.element = element.cloneNode(true);
// For cleanup
this.originalId = this.element.id;
this.originalElement = this.element.cloneNode(true);
// Important to change the id to "hide" the clone
this.originalElement.id = getId("o-");
this.data = data || { };
this.static_replay_id = getId("r-");
}
getStaticId()
{
return this.static_replay_id;
}
// Used to clean "dirtied" elements before replaying.
// Consider the event flow:
// 1. Create element X
// 2. Change X CSS or subinsert to X
// 3. Remove X
// 4. Replay
// Without cleanup, during replay at step 1, the created element
// will already have "later" CSS changes and subinserts applied to it ahead of time
// because of ""the way we do things"".
// This fixes that issue
__clean()
{
// OLD CLEANUP METHOD
/*while (this.element.lastElementChild) {
this.element.lastElementChild.style = "";
this.element.removeChild(this.element.lastElementChild);
}
this.element.style = "";
this.element.innerText = this.element.id;*/
// NEW CLEANUP METHOD (don't have to care what changed)
// Reset event's element
this.element = this.originalElement.cloneNode(true);
// Must remember to set the original id back
// Or else it will be the clone's id
this.element.id = this.originalId;
}
replay($targetid, useActor = false)
{
let elem = null;
let success = false;
this.__clean();
switch(this.event)
{
case 'TEXT':
elem = document.querySelector(`#${$targetid} div#${this.element.id}`);
if(elem) {
elem.replaceChild(document.createTextNode(this.data.text), elem.childNodes[0]);
success = true;
}
break;
case 'INSERT':
elem = document.querySelector(`#${$targetid}`);
if(elem) {
let actorElement = null;
if(useActor) {
actorElement = this.element.cloneNode(true);
actorElement.id = getId();
}
else {
actorElement = this.element;
}
elem.append(actorElement);
success = true;
}
break;
case 'SUBINS':
elem = document.querySelector(
`#${$targetid} div#${
this.recorderInstance.getEventTargetByStaticReplayId(this.data.subtree_target)
}`);
if(elem) {
let actorElement = null;
if(useActor) {
actorElement = this.element.cloneNode(true);
actorElement.id = getId();
}
else {
actorElement = this.element;
}
elem.append(actorElement);
success = true;
}
break;
case 'CSS':
elem = document.querySelector(`#${$targetid} div#${this.element.id}`);
if(elem && typeof this.data.css_rules !== 'undefined') {
this.data.css_rules.forEach((r) => {
elem.style[r.rule] = r.value;
});
success = true;
}
break;
case 'REMOVE':
elem = document.querySelector(`#${$targetid} div#${this.element.id}`);
if(elem) {
elem.remove();
success = true;
}
break;
case 'SUBREM':
elem = document.querySelector(
`#${$targetid} div#${
this.recorderInstance.getEventTargetByStaticReplayId(this.data.subtree_target)
}`);
if(elem) {
elem.remove();
success = true;
}
break;
default:
break;
}
return success;
}
toString()
{
return `${this.event}: ${this.element.id}`;
}
}
// Dom recorder / MutationObserver stuff
class DOMRecorder
{
constructor(targetNode, config)
{
this.recording = false;
this.replaying = false;
this.targetNode = targetNode;
this.config = config;
this.observer = null;
this.elementIds = [];
this.events = [];
}
start()
{
if(this.observer === null)
this.observer = new MutationObserver(this.recordEvent.bind(this));
this.observer.observe(this.targetNode, this.config);
this.recording = true;
}
getStaticIdForMutationTarget(targetid)
{
return this.events.find((evt) => evt.element.id === targetid).static_replay_id;
}
getEventByTargetId(targetid)
{
return this.events.find((evt) => evt.element.id === targetid);
}
getEventTargetByStaticReplayId(replay_id)
{
let targetid = null;
for(let i = 0; i < this.events.length; i++) {
let evt = this.events[i];
if(!"static_replay_id" in evt.data)
continue;
if(evt.static_replay_id === replay_id) {
targetid = evt.element.id;
break;
}
}
return targetid;
}
stop()
{
this.observer.disconnect();
this.recording = false;
}
update()
{
this.updateElementList();
this.updateEventList();
}
updateElementList()
{
let options = this.elementIds.map((id) => {
let option = document.createElement("option");
option.value = id;
option.innerText = id;
return option;
});
$elementlist.replaceChildren(...options);
}
updateEventList()
{
let options = this.events.map((e) => {
let option = document.createElement("option");
// This line breaks syntax-highlighting, if `` is used???
option.value = e.event + ";" + e.element.id;
option.innerText = e.toString();
return option;
});
$eventlist.replaceChildren(...options);
}
recordEvent(mutationList, observer)
{
for(const mutation of mutationList) {
// Element text changed event
if(mutation.type === 'childList' &&
mutation.addedNodes.length > 0 &&
mutation.addedNodes[0].nodeName === '#text') {
this.events.push(new MutationEvent(
this,
"TEXT",
mutation.addedNodes[0].parentElement,
{ text: mutation.addedNodes[0].nodeValue }
));
}
// Element added event
else if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
// Element added directly into the recorded element
if(mutation.target.id === this.targetNode.id) {
this.events.push(new MutationEvent(this, "INSERT", mutation.addedNodes[0]));
this.elementIds.push(mutation.addedNodes[0].id);
}
// Element added to a subtree of some element in the recorded element
else {
this.events.push(new MutationEvent(
this,
"SUBINS",
mutation.addedNodes[0],
{ subtree_target: this.getStaticIdForMutationTarget(mutation.target.id) }
));
this.elementIds.push(mutation.addedNodes[0].id);
}
}
// Element removed event
else if (mutation.type === 'childList' && mutation.removedNodes.length > 0) {
// Element removed directly from the recorded element
if(mutation.target.id === this.targetNode.id) {
this.events.push(new MutationEvent(this, "REMOVE", mutation.removedNodes[0]));
}
// Element removed from a subtree of some element in the recorded element
else {
this.events.push(new MutationEvent(
this,
"SUBREM",
mutation.removedNodes[0],
{ subtree_target: this.getStaticIdForMutationTarget(mutation.removedNodes[0].id) }
));
}
this.elementIds.splice(this.elementIds.indexOf(mutation.removedNodes[0].id), 1);
// Clean up element list, if we remove a parent element which has child elements
let childNodes = mutation.removedNodes[0].getElementsByTagName('*');
Array.from(childNodes).forEach((n) => {
this.elementIds.splice(this.elementIds.indexOf(n.id), 1);
});
}
// Element inline CSS changed
else if(mutation.type === 'attributes' && mutation.attributeName === "style") {
// Perform some magic to find specifically only the changed
// inline style names of the element, as well as their values
let addedStyles = Object.keys(mutation.target.style).filter((k) => {
return Number.isInteger(parseInt(k));
}).map((styleKey) => {
// Change rule names like "background-color" -> "backgroundColor"
let rule = mutation.target.style[styleKey]
.replace(/-(.)/g, (m,p) => p.toUpperCase());
let value = mutation.target.style[rule];
return { rule, value }
});
this.events.push(new MutationEvent(
this,
"CSS",
mutation.target,
{ css_rules: addedStyles }
));
}
}
this.update();
}
replayEvent(event, id, $targetid)
{
let replayEvent = this.events
.find((evt) => evt.event === event && evt.element.id === id);
if(!replayEvent) {
console.log(`Could not find event: "${event}", ID: "${id}"`);
return;
}
replayEvent.replay($targetid, true);
}
replayEvents(speed, $targetid)
{
if(this.replaying)
return;
this.replaying = true;
if(this.recording)
this.stop();
$recordable.innerHTML = '';
let i = 0;
let replayInterval = setInterval(() => {
if(i < this.events.length) {
this.events[i].replay($targetid);
i++;
}
else {
clearInterval(replayInterval);
this.replaying = false;
this.start();
}
}, speed);
}
}
// Example controls events
function addElement() {
let id = getId();
let div = document.createElement("div");
div.id = id;
div.innerHTML = id;
$recordable.append(div);
}
function addElementSubtree() {
let selected = $elementlist.value || null;
if(selected === null) {
console.log('No element selected');
return;
}
let id = getId();
let div = document.createElement("div");
div.id = id;
div.innerHTML = id;
let elem = document.querySelector(`#recordable div#${selected}`);
if(elem)
elem.append(div);
}
function changeCSS() {
let selected = $elementlist.value || null;
if(selected === null) {
console.log('No element selected');
return;
}
let userCSS = prompt("Input a single CSS rule like background-color: red");
userCSS = userCSS.length === 0 ? "background-color: red" : userCSS;
let [cssRuleName, cssRule] = userCSS.split(':').map((s) => s.trim());
cssRuleName = cssRuleName.replace(/-(.)/g, (m,p) => p.toUpperCase());
let elem = document.querySelector(`#recordable div#${selected}`);
if(elem)
elem.style[cssRuleName] = cssRule;
}
function removeElement() {
let selected = $elementlist.value || null;
if(selected === null) {
console.log('No element selected');
return;
}
let elem = document.querySelector(`#recordable div#${selected}`);
if(elem)
elem.remove();
}
function changeText()
{
let selected = $elementlist.value || null;
if(selected === null) {
console.log('No element selected');
return;
}
let elem = document.querySelector(`#recordable div#${selected}`);
if(elem) {
let text = prompt("Insert text for element");
// Can't use elem.textContent / innerText / innerHTML...
// might accidentally overwrite element's subtree
// Can't do it like this, because MutationObserver can't see this (why??)
// elem.childNodes[0].value = text;
// This is the way
elem.replaceChild(document.createTextNode(text), elem.childNodes[0]);
}
}
function replayOne()
{
let selected = $eventlist.value || null;
if(selected === null) {
console.log('No element selected');
return;
}
let [event, id] = selected.split(';');
recorder.replayEvent(event, id, $recordable.id);
}
function replayAll()
{
let speed = prompt("Input speed in ms for the replay") || 350;
recorder.replayEvents(speed, $recordable.id);
}
const recorder = new DOMRecorder(
$recordable,
{ attributes: true, childList: true, subtree: true }
);
recorder.start();
*
{
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body
{
width: 100%;
height: 100%;
}
#recordable
{
width: 100%;
height: calc(100% - 35px);
background-color: #7f8fa6;
padding: 5px;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
-webkit-align-content: flex-start;
-ms-flex-line-pack: start;
align-content: flex-start;
-webkit-align-items: flex-start;
-ms-flex-align: start;
align-items: flex-start;
}
#controls
{
width: 100%;
height: 80px;
background-color: #273c75;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-flex-wrap: nowrap;
-ms-flex-wrap: nowrap;
flex-wrap: nowrap;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-content: stretch;
-ms-flex-line-pack: stretch;
align-content: stretch;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
}
button, select
{
padding: 2px;
margin: 0 3px 0 3px;
}
#recordable > div
{
width: 120px;
min-height: 35px;
padding: 4px;
line-height: calc(35px - 4px);
background-color: #2f3640;
color: #dcdde1;
font-family: "courier-new", Arial;
font-size: 10pt;
margin: 0px 5px 5px 5px;
text-align: center;
}
#recordable > div > *
{
padding: 0px;
line-height: calc(35px - 4px);
background-color: #192a56;
color: #dcdde1;
font-family: "courier-new", Arial;
font-size: 10pt;
margin: 5px 5px 5px 5px !important;
text-align: center;
}
<div id = "controls">
<button id = "add-control" onClick = "addElement()">Add</button>
<button id = "add-subtree-control" onClick = "addElementSubtree()">Add sub</button>
<button id = "add-control" onClick = "removeElement()">Remove</button>
<button id = "css-control" onClick = "changeCSS()">CSS</button>
<button id = "edit-control" onClick = "changeText()">Edit</button>
<select id = "element-list">
</select>
<select id = "event-list">
</select>
<button id = "replay-control" onClick = "replayOne()">Replay</button>
<button id = "replay-all-control" onClick = "replayAll()">Replay all</button>
</div>
<div id = "recordable">
</div>

Categories