springspring-mvcthymeleafspring-web

Dynamically add rows to table in form by using AJAX


I have a form which encloses a table. I want to be able to add rows dynamically to this table by sending an AJAX request and appending the result to the table body. This request should trigger the rendering of a template and add a corresponding element to my data transfer object. This enables me to comfortable save the object when the user presses the submit button.

My question is which kind of handler method do I need to define in the controller to add this row to my model? How can I pass the model by using my AJAX request? Can this be done at all?

Below you can find the snippet for creating the table:

<div class="form-group form-row">
    <button type="submit" id="addAttribute" name="addAttribute" class="btn btn-success">
        <i class="fa fa-fw fa-plus"></i> New attribute
    </button>
</div>
<table>
    <thead>
        <tr>
            <th>&nbsp;</th>
            <th>Attribute name</th>
        </tr>
    </thead>
    <tbody id="attributeList">
    </tbody>
</table>

This is my data transfer object:

public class IEForm
{
    private String name;
    // ...

    // IEAttribute is defined somewhere else, but this doesn't matter right now
    private List<IEAttribute> attributes;

    // getters and setters

    public List<IEAttribute> getAttributes()
    {
        return this.attributes;
    }

    public void setAttributes(List<IEAttribute> attributes)
    {
        this.attributes = attributes;
    }
}

My controller's handler methods for creating the form page and for the AJAX request:

@RequestMapping("/create")
String create(Model model)
{
    model.addAttribute("form", new IEForm());
    return "iecreation/creation";
}

@RequestMapping(value = "/addAttribute", params = {"addAttribute"})
public String addAttribute(IEForm dto, BindingResult result)
{
    dto.getAttributes().add(new IEAttribute());
    return "iecreation/newAttribute";
}

The AJAX page:

<tr>
    <td><i class="fa fa-fw fa-key"></i></td>
    <td>
        <input type="text" class="form-control col-lg-8" placeholder="Attributname"
            th:field="???"/>
    </td>
</tr>

The JavaScript code for adding the AJAX response to the table

$(document).ready(function()
{
    $("#newAttribute").on("click", function()
    {
        $.ajax({
            type: "GET",
            url: "/ie/create/newAttribute",
            success: function(response)
            {
                $("#attributeList").append(response);
            }
        });
    });
});

I've tried this approach, but it didn't behave as expected, as my form was constantly submitted (the wrong handler was executed and the page needed to be reloaded).

This answer couldn't help me, as it's valid for JSP.

A colleague suggested added a large number of hidden divs and then dynamically make them visible by clicking the button. However, I dislike this solution, as it's not very clean and the number of attributes is restricted.

Thanks for your support!


Solution

  • We finally went down the path of using hidden fields and displaying them dynamically via JavaScript.

    The controller creates a list with a size of one hundred DTO objects. The template renders every DTO object, assigns its field property with the index, and adds a CSS class, called d-none, that makes them invisible. In the th:field attribute the index of the array is specified, so Spring can correctly map the form data to the DTOs.

    <tr class="d-none form-group">
        <th><i class="fa fa-fw fa-minus-circle" th:id="|remove-${iterator.index}|"></i></th>
        <td>&nbsp;</td>
        <td>
            <input  type="text" class="form-control col-lg-8" placeholder="Attributname"
                    th:field="*{attributes[__${iterator.index}__].name}"
                    th:id="|attributeName-${iterator.index}|"/>
        </td>
    </tr>
    

    In the JavaScript part the d-none CSS class is dynamically added and removed when the user navigates among the objects. We use the content of the attribute id to get the index of the currently visible attribute.

    As requested, I attached the source class for the DTO class with the 100 attributes created for it:

    public class IEForm {
        private static final int ATTRIBUTE_MAX_COUNT = 100;
    
        @NotNull
        @NotEmpty
        @Valid
        private List<IEAttributeForm> attributes;
    
        public void removeCommaFromAttributeNames() {
            for (IEAttributeForm processed : this.attributes) {
                processed.setName(
                        processed.getName().substring(
                                0,
                                processed.getName().indexOf(',')
                        )
                );
            }
        }
    
        public List<IEAttributeForm> getFilledOutAttributes() {
            List<IEAttributeForm> filledOutAttributes = new ArrayList<>();
            int i = 0;
            while (
                    (i < ATTRIBUTE_MAX_COUNT)
                    && (this.attributes.get(i).getName() != null)
                    && (this.attributes.get(i).getName().length() > 0)
                    && (!Objects.equals(this.attributes.get(i).getPhysicalName(), IEAttributeForm.DEFAULT_NAME))
                    && (!Objects.equals(this.attributes.get(i).getAttributeName(), IEAttributeForm.DEFAULT_NAME))
            ) {
                filledOutAttributes.add(this.attributes.get(i));
                ++i;
            }
    
            return filledOutAttributes;
        }
    
        public void initialize() {
            for (int i = NUMBER_OF_INITIAL_ATTRIBUTES; i < ATTRIBUTE_MAX_COUNT; ++i) {
                this.attributes.add(new IEAttributeForm(i));
            }
        }
    }
    

    Here is the controller for processing the attributes:

    @Controller
    @RequestMapping("/ie")
    public class InformationEntityController {
        // ...
    
        @RequestMapping("/create")
        String create(@RequestParam(name = "search", required = false, defaultValue = "") String searchText, Model model) {
            IEForm infoEntity = new IEForm();
            infoEntity.initialize();
            this.initializeInfoEntityCreationFormParameters(model, infoEntity, searchText);
            return "iecreation/creation";
        }
    
        @RequestMapping("/save")
        String save(
                @Valid @ModelAttribute("form") IEForm dto,
                BindingResult bindingResult,
                Model model
        ) {
            dto.removeCommaFromAttributeNames();
    
            InfoEntity created = new InfoEntity();
            created.setDescription(dto.getDescription());
            created.setDisplayName(dto.getName());
            created.setVersion(dto.getVersionAlinter());
            created.setEditable(dto.isEditable());
            created.setActive(dto.isActive());
            created.setComplete(dto.isComplete());
    
            created.setNameALINTER(dto.getNameAlinter());
            created.setVersionALINTER(dto.getVersionAlinter());
            created.setNameCAR(dto.getCarObjectName());
    
            if (!bindingResult.hasErrors()) {
                Optional<Application> ieApplication = this.applicationRepository.findById(dto.getApplicationId());
                if (ieApplication.isPresent()) {
                    created.setApplication(ieApplication.get());
                }
    
                ArrayList<IEAttribute> attributes = new ArrayList<>();
                for (IEAttributeForm processedAttribute : dto.getFilledOutAttributes()) {
                    IEAttribute addedAttribute = new IEAttribute();
    
                    addedAttribute.setType(processedAttribute.getDataType());
                    addedAttribute.setAttributeName(processedAttribute.getName());
                    addedAttribute.setPhysicalName(processedAttribute.getPhysicalName());
                    addedAttribute.setPhysicalLength(processedAttribute.getPhysicalLength());
                    addedAttribute.setFieldType(processedAttribute.getFieldType());
                    addedAttribute.setAttributeDescription(processedAttribute.getAttributeDescription());
                    addedAttribute.setHasHW(processedAttribute.getHasHint());
                    addedAttribute.setMaxLength(processedAttribute.getMaxLength());
                    addedAttribute.setEnumName(processedAttribute.getEnumName());
                    addedAttribute.setPrecision(processedAttribute.getPrecision());
    
                    attributes.add(addedAttribute);
                }
                created.setAttributes(attributes);
    
                this.ieService.saveInfoEntity(created);
                return "redirect:/download/ie?infoEntityId=" + created.getId();
            }
            else {
                this.initializeInfoEntityCreationFormParameters(model, dto, "");
                return "iecreation/creation";
            }
        }
    }
    

    Finally, here is the TypeScript code for switching between the attributes:

    const ADDITIONAL_ATTRIBUTE_COUNT = 100;
    let visibleAttributeCount = 0;
    let previousAttributeIndex = (-1);
    
    function initializeAttributes() {
        visibleAttributeCount = getNumberOfActiveAttributes();
        for (let i = 0; i < visibleAttributeCount; ++i) {
            addAttribute(i);
        }
        getTableNode(0).removeClass("d-none");
        applyValidationConstraints(0);
        previousAttributeIndex = 0;
    
        $("#addAttribute").on("click", function(event): boolean {
            event.preventDefault();
    
            addAttribute(visibleAttributeCount);
            ++visibleAttributeCount;
            return false;
        });
    
        const totalAttributeCount = ADDITIONAL_ATTRIBUTE_COUNT + visibleAttributeCount;
        const INDEX_ID_ATTRIBUTE = 1;
        for (let i = 0; i < totalAttributeCount; ++i) {
            $("#previousTable-" + i).on("click", function(event) {
                event.preventDefault();
                let attributeId = extractAttributeId(event.target.attributes[INDEX_ID_ATTRIBUTE].value);
                if (attributeId > 0) {
                    --attributeId;
                    switchToTable(attributeId);
                }
            });
            $("#nextTable-" + i).on("click", function(event) {
                event.preventDefault();
                let attributeId = extractAttributeId(event.target.attributes[INDEX_ID_ATTRIBUTE].value);
                if (attributeId < (visibleAttributeCount - 1)) {
                    ++attributeId;
                    switchToTable(attributeId);
                }
            });
            $("#attributeName-" + i).on("keyup", function(event) {
                event.preventDefault();
                for (let processedAttribute of event.target.attributes) {
                    if (processedAttribute.name === "id") {
                        let attributeId = extractAttributeId(processedAttribute.value);
                        $("#tableHeading-" + attributeId).text(String($(event.target).val()));
                    }
                }
            })
            $("#attributes-" + i + "_dataType").on("change", function(event) {
                event.preventDefault();
                for (let processedAttribute of event.target.attributes) {
                    if (processedAttribute.name === "id") {
                        let attributeId = extractAttributeId(processedAttribute.value);
                        applyValidationConstraints(attributeId);
                    }
                }
            });
        }
    }
    
    function getNumberOfActiveAttributes(): number {
        let numberOfActiveAttributes = 0;
    
        const attributeRows = $("#attributeList");
        attributeRows.children("tr").each(function(index, element) {
            const attributeName: string = String($(element).children("td").last().children("input").val());
            if ((attributeName != null) && (attributeName !== "")) {
                ++numberOfActiveAttributes;
            }
        });
    
        return numberOfActiveAttributes;
    }
    
    function addAttribute(attributeIndex: number) {
        let rowNode = getAttributeRow(attributeIndex);
        rowNode.removeClass("d-none");
        $("td:eq(1)>input", rowNode).attr("required", "");
    }
    
    function getAttributeRow(attributeNumber: number) {
        return $("#attributeList>tr:eq(" + attributeNumber + ")");
    }
    
    function extractAttributeId(fullyQualifiedId: string): number {
        return parseInt(fullyQualifiedId.substring((fullyQualifiedId.indexOf("-") + 1)));
    }
    
    function switchToTable(attributeIndex: number) {
        if (previousAttributeIndex !== (-1)) {
            getTableNode(previousAttributeIndex).addClass("d-none");
        }
        previousAttributeIndex = attributeIndex;
    
        let currentTableNode = getTableNode(attributeIndex);
    
        applyValidationConstraints(attributeIndex);
        currentTableNode.removeClass("d-none");
    }
    
    function getTableNode(attributeIndex: number): JQuery<HTMLElement> {
        return $("#table-" + attributeIndex);
    }
    
    function applyValidationConstraints(attributeId: number) {
        const currentDataTypeName = String($("#attributes-" + attributeId + "_dataType").val());
        if (validationRules.has(currentDataTypeName)) {
            $(".affectedByDataType-" + attributeId).addClass("d-none");
            const dataType = validationRules.get(currentDataTypeName);
            if (dataType != null) {
                for (let processedAttribute of dataType.attributes) {
                    makeAttributeVisible(attributeId, processedAttribute);
                }
            }
        } else {
            $(".affectedByDataType-" + attributeId).removeClass("d-none");
        }
    }
    
    function makeAttributeVisible(attributeId: number, processedAttribute: ValidationAttribute) {
        $("#attributeRow-" + attributeId + "_" + processedAttribute.id).removeClass("d-none");
        const attributeInputNode = $("#attributes-" + attributeId + "_" + processedAttribute.id);
        attributeInputNode.attr("type", processedAttribute.type);
        if (processedAttribute.pattern != null) {
            attributeInputNode.attr("pattern", processedAttribute.type);
        } else {
            attributeInputNode.removeAttr("pattern");
        }
    }