Welcome to the forum @miguelclaramunt! 
What you're describing should definitely be possible with custom html, css and js.
I think the main problem in your current implementation is the container structure. By using container.parentNode.insertBefore
you are inserting the new row as a sibling to the container, not inside it.
In order to save the data introduced via this form, you will have to call prodigy.update
function in the javascript handler of the form.
Since it requires a couple of custom javascript functions (for adding rows, saving the data and resetting the rows) I split the solution into html, css and javascript files to make it more readable and to be able to pass javascript code to Prodigy.
Starting with the html:
Since we are not going to use any variables, we don't really need a template. Instead, we can just pass the following html string to html
view:
<div id="form-container"></div>
<button id="add-row-btn">Add More Information</button>
Here we define just the form container and the button.
The rows will be filled by a javascript script function just like in your implementation.
The javascript will have to take care of:
- Defining a function to add new rows - exactly like in your implementation
function createRow() {
const row = document.createElement('div');
row.className = 'row-container';
const termInput = document.createElement('input');
termInput.placeholder = 'Term';
termInput.className = 'term-input';
const justificationInput = document.createElement('input');
justificationInput.placeholder = 'Justification';
justificationInput.className = 'justification-input';
const errorSelect = document.createElement('select');
errorSelect.className = 'error-select';
const errorOptions = ['Error 1', 'Error 2', 'Error 3'];
errorOptions.forEach(opt => {
const option = document.createElement('option');
option.textContent = opt;
errorSelect.appendChild(option);
});
const severitySelect = document.createElement('select');
severitySelect.className = 'severity-select';
const severityOptions = ['Critical', 'Major', 'Minor'];
severityOptions.forEach(opt => {
const option = document.createElement('option');
option.textContent = opt;
severitySelect.appendChild(option);
});
// Append all elements to the row
row.appendChild(termInput);
row.appendChild(justificationInput);
row.appendChild(errorSelect);
row.appendChild(severitySelect);
return row;
}
- Adding the first 2 rows:
const formContainer = document.getElementById('form-container');
formContainer.appendChild(createRow());
formContainer.appendChild(createRow());
Contrary to your implementation, we are appending as children to the form container.
- Add a listener to the button with createRow callback:
const addButton = document.getElementById('add-row-btn');
addButton.addEventListener('click', function() {
formContainer.appendChild(createRow());
});
- Add listeners to the form fields so that the internal form structure is updated every time the user modifies the form. This done by calling
prodigy.update
function that updates the Prodigy task by perfoming a shallow dictionary merge:
formContainer.addEventListener('input', function(event) {
// Check if the event target is an input or select element
if (event.target.tagName === 'INPUT' || event.target.tagName === 'SELECT') {
// Update Prodigy task whenever any input or select value changes
updateProdigyTask();
}
});
// Also listen for change events (for selects)
formContainer.addEventListener('change', function(event) {
if (event.target.tagName === 'SELECT') {
updateProdigyTask();
}
});
- Define the
updateProdigyTask
function:
function updateProdigyTask() {
// Collect all row containers
const rowContainers = document.querySelectorAll('.row-container');
// Create an array to store the form data
const formData = [];
// Iterate through each row and extract values
rowContainers.forEach((row) => {
// Use more specific selectors to get the correct inputs
const termInput = row.querySelector('input[placeholder="Term"]');
const justificationInput = row.querySelector('input[placeholder="Justification"]');
const errorSelect = row.querySelector('select:first-of-type');
const severitySelect = row.querySelector('select:last-of-type');
// Create an object for this row
const rowData = {
term: termInput.value.trim(),
justification: justificationInput.value.trim(),
errorType: errorSelect.value,
severity: severitySelect.value
};
// Only add non-empty rows (at least term or justification must be filled)
if (rowData.term || rowData.justification) {
formData.push(rowData);
}
});
// Update the Prodigy task with the collected form data
// this will add a new key `form_field` to Prodigy task saved in the DB
prodigy.update({ "form_field": formData });
console.log('Updated Prodigy task with form data:', formData);
}
- We'll also need a form reset function to make sure that the new task gets an empty form. For that we'll listen to
prodigyanswer
event and call the function whenever the user submits the task:
document.addEventListener('prodigyanswer', () => {
// Reset the form after saving
resetForm();
});
function resetForm() {
const formContainer = document.getElementById('form-container');
// Clear all existing rows
formContainer.innerHTML = '';
// Add back two empty rows (default state)
formContainer.appendChild(createRow());
formContainer.appendChild(createRow());
console.log('Form has been reset');
}
Finally the suggested styling:
.row-container {
display: flex;
margin-bottom: 10px;
align-items: center;
}
input, select {
padding: 6px;
margin-right: 10px;
border: 1px solid #ccc;
}
input {
width: 180px;
}
select {
width: 90px;
}
#add-row-btn {
background-color: #5a3cc0;
color: white;
border: none;
padding: 10px 20px;
text-transform: uppercase;
font-weight: bold;
cursor: pointer;
}
You can plug in the html, css, and javascript in the recipe like so:
from pathlib import Path
import prodigy
from prodigy.components.stream import get_stream
from prodigy.core import Arg
@prodigy.recipe(
"form",
dataset=Arg(help="Dataset to save answers to"),
source=Arg(help="Input dataset"),
)
def form(dataset: str, source: Path):
stream = get_stream(source)
custom_js = Path("custom.js").read_text()
html = Path("form.html").read_text()
custom_css = Path("custom.css").read_text()
blocks = [
{"view_id": "text"},
{"view_id": "html", "html": html},
]
return {
"dataset": dataset,
"stream": stream,
"view_id": "blocks",
"config": {
"blocks": blocks,
"global_css": custom_css,
"javascript": custom_js,
},
}
This should result in the following UI:
And the resulting structure of a saved task in the database should be:
{
"text": "Uber’s Lesson: Silicon Valley’s Start-Up Machine Needs Fixing",
"meta": {
"source": "The New York Times"
},
"html": " ",
"_input_hash": -1450484177,
"_task_hash": 178937675,
"_view_id": "blocks",
"form_field": [
{
"term": "a",
"justification": "foo bar",
"errorType": "Error 1",
"severity": "Critical"
},
{
"term": "b",
"justification": "baz",
"errorType": "Error 2",
"severity": "Minor"
},
{
"term": "c",
"justification": "qux",
"errorType": "Error 3",
"severity": "Major"
}
],
"answer": "accept",
"_timestamp": 1744206583,
"_annotator_id": "2025-04-09_15-48-35",
"_session_id": "2025-04-09_15-48-35"
}
As you can see there's form_field
key where each row is saved as dictionary.
Please note that this does not handle the undo
button. Currently, if the user hits the undo
button the form will be reset. To preserve the custom front-end introduced information, you'd have to implement a buffer where you'd store the last x items in memory and populate the form as needed.
For convenience, here's the full javascript file. I also wrapped the custom javascript code in prodigymount
event listener to make sure the app is fully loaded before attempting to modify the DOM.
document.addEventListener('prodigymount', () => {
// Add initial rows
const formContainer = document.getElementById('form-container');
formContainer.appendChild(createRow());
formContainer.appendChild(createRow());
// Add event listener for the button
const addButton = document.getElementById('add-row-btn');
addButton.addEventListener('click', function() {
formContainer.appendChild(createRow());
});
// Function to collect form data and update Prodigy task
function updateProdigyTask() {
// Collect all row containers
const rowContainers = document.querySelectorAll('.row-container');
// Create an array to store the form data
const formData = [];
// Iterate through each row and extract values
rowContainers.forEach((row) => {
// Use more specific selectors to get the correct inputs
const termInput = row.querySelector('input[placeholder="Term"]');
const justificationInput = row.querySelector('input[placeholder="Justification"]');
const errorSelect = row.querySelector('select:first-of-type');
const severitySelect = row.querySelector('select:last-of-type');
// Create an object for this row
const rowData = {
term: termInput.value.trim(),
justification: justificationInput.value.trim(),
errorType: errorSelect.value,
severity: severitySelect.value
};
// Only add non-empty rows (at least term or justification must be filled)
if (rowData.term || rowData.justification) {
formData.push(rowData);
}
});
// Update the Prodigy task with the collected form data
// this will add a new key `form_field` to Prodigy task saved in the DB
prodigy.update({ "form_field": formData });
console.log('Updated Prodigy task with form data:', formData);
}
// Add event listeners to the form container to detect changes in any input or select
formContainer.addEventListener('input', function(event) {
// Check if the event target is an input or select element
if (event.target.tagName === 'INPUT' || event.target.tagName === 'SELECT') {
// Update Prodigy task whenever any input or select value changes
updateProdigyTask();
}
});
// Also listen for change events (for selects)
formContainer.addEventListener('change', function(event) {
if (event.target.tagName === 'SELECT') {
updateProdigyTask();
}
});
document.addEventListener('prodigyanswer', () => {
// Reset the form after saving
resetForm();
});
});
// Function to create a new row
function createRow() {
const row = document.createElement('div');
row.className = 'row-container';
// Create elements explicitly instead of using innerHTML
const termInput = document.createElement('input');
termInput.placeholder = 'Term';
termInput.className = 'term-input';
const justificationInput = document.createElement('input');
justificationInput.placeholder = 'Justification';
justificationInput.className = 'justification-input';
const errorSelect = document.createElement('select');
errorSelect.className = 'error-select';
const errorOptions = ['Error 1', 'Error 2', 'Error 3'];
errorOptions.forEach(opt => {
const option = document.createElement('option');
option.textContent = opt;
errorSelect.appendChild(option);
});
const severitySelect = document.createElement('select');
severitySelect.className = 'severity-select';
const severityOptions = ['Critical', 'Major', 'Minor'];
severityOptions.forEach(opt => {
const option = document.createElement('option');
option.textContent = opt;
severitySelect.appendChild(option);
});
// Append all elements to the row
row.appendChild(termInput);
row.appendChild(justificationInput);
row.appendChild(errorSelect);
row.appendChild(severitySelect);
return row;
}
// Function to reset the form
function resetForm() {
const formContainer = document.getElementById('form-container');
// Clear all existing rows
formContainer.innerHTML = '';
// Add back two empty rows (default state)
formContainer.appendChild(createRow());
formContainer.appendChild(createRow());
console.log('Form has been reset');
}
Let me know if you have any questions!