You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Thymeleaf/Spring MVC如何处理嵌套列表表单绑定问题

Got it, let's work through how to handle this nested list binding in Thymeleaf + Spring MVC, and get your work done sentences tied to the correct job entries. Here's a practical, step-by-step solution:


1. Update Your Table Row to Pass Job Context to the Modal

First, modify your "Work done" button to carry the index of the current job row, and add a data attribute to store existing work done sentences for that job. This lets the modal know exactly which job it's working with:

<tbody>
  <tr id='addr_work[[${rowStat.index}]]' 
      th:each="workRow, rowStat : *{workHistoryDetails.allWorkHistoryData}"
      th:data-work-done="${#json.toJson(workRow.workDoneSentences)}">
    <td th:text="${rowStat.index + 1}"></td>
    <td><input type="text" class="form-control" th:field="*{workHistoryDetails.allWorkHistoryData[__${rowStat.index}__].companyName}" placeholder='Company Name'/></td>
    <td><input type="text" class="form-control" th:field="*{workHistoryDetails.allWorkHistoryData[__${rowStat.index}__].city}" placeholder='Company city'/></td>
    <td><input type="text" class="form-control" th:field="*{workHistoryDetails.allWorkHistoryData[__${rowStat.index}__].jobTitle}" placeholder='Job Title'/></td>
    <td><input type="checkbox" class="form-control" style="text-align: center;" th:field="*{workHistoryDetails.allWorkHistoryData[__${rowStat.index}__].currentJob}"></td>
    <td><input type="text" class="form-control" th:field="*{workHistoryDetails.allWorkHistoryData[__${rowStat.index}__].startDate}" placeholder='Start Date'/></td>
    <td><input type="text" class="form-control" th:field="*{workHistoryDetails.allWorkHistoryData[__${rowStat.index}__].endDate}" placeholder='End Date'/></td>
    <td>
      <a class="btn btn-primary btn-md work-done-btn" 
         data-job-index="${rowStat.index}"
         data-bs-toggle="modal" 
         data-bs-target="#workDoneModal">Work done</a>
    </td>
  </tr>
</tbody>
  • data-job-index stores the current job's position in the list
  • data-work-done serializes the existing work done sentences to JSON, so we can load them into the modal later

2. Build the Work Done Modal with Dynamic Binding

Create a Bootstrap modal that dynamically generates input fields bound to the correct job's workDoneSentences list. It will support editing existing entries and adding new ones:

<!-- Work Done Modal -->
<div class="modal fade" id="workDoneModal" tabindex="-1" aria-labelledby="workDoneModalLabel" aria-hidden="true">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title" id="workDoneModalLabel">Add/Edit Work Done</h5>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <div class="modal-body">
        <!-- Hidden input to track current job index -->
        <input type="hidden" id="currentJobIndex">
        
        <!-- Container for work done input rows -->
        <div id="workDoneInputsContainer" class="space-y-2"></div>
        
        <button type="button" class="btn btn-secondary mt-3" id="addNewWorkDoneRow">Add Another Item</button>
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
        <button type="button" class="btn btn-primary" id="saveWorkDone">Save Changes</button>
      </div>
    </div>
  </div>
</div>

3. JavaScript to Manage Modal Behavior

Add this script to handle loading existing data, adding/removing rows, and ensuring correct form binding:

let currentJobIndex = 0;
const modal = new bootstrap.Modal(document.getElementById('workDoneModal'));

// Open modal and load existing work done sentences
document.querySelectorAll('.work-done-btn').forEach(btn => {
  btn.addEventListener('click', () => {
    currentJobIndex = btn.getAttribute('data-job-index');
    document.getElementById('currentJobIndex').value = currentJobIndex;
    
    const container = document.getElementById('workDoneInputsContainer');
    container.innerHTML = ''; // Clear old content
    
    // Load existing sentences from the row's data attribute
    const existingSentences = JSON.parse(btn.closest('tr').getAttribute('data-work-done')) || [];
    
    // Render existing rows
    existingSentences.forEach((sentence, idx) => {
      renderWorkDoneRow(idx, sentence);
    });
    
    // Add empty row if no sentences exist
    if (existingSentences.length === 0) {
      renderWorkDoneRow(0, '');
    }
  });
});

// Render a single work done input row
function renderWorkDoneRow(index, value) {
  const container = document.getElementById('workDoneInputsContainer');
  const row = document.createElement('div');
  row.className = "d-flex gap-2";
  
  // Input field with correct Spring MVC binding name
  const input = document.createElement('input');
  input.type = 'text';
  input.className = 'form-control flex-grow-1';
  input.placeholder = 'Enter work done item';
  input.name = `workHistoryDetails.allWorkHistoryData[${currentJobIndex}].workDoneSentences[${index}]`;
  input.value = value;
  
  // Remove button
  const removeBtn = document.createElement('button');
  removeBtn.type = 'button';
  removeBtn.className = 'btn btn-danger';
  removeBtn.textContent = '×';
  removeBtn.addEventListener('click', () => {
    row.remove();
    reindexWorkDoneRows(); // Fix indexes after removal
  });
  
  row.appendChild(input);
  row.appendChild(removeBtn);
  container.appendChild(row);
}

// Add new empty row
document.getElementById('addNewWorkDoneRow').addEventListener('click', () => {
  const container = document.getElementById('workDoneInputsContainer');
  const nextIndex = container.children.length;
  renderWorkDoneRow(nextIndex, '');
});

// Reindex rows to maintain correct list order for binding
function reindexWorkDoneRows() {
  const rows = document.querySelectorAll('#workDoneInputsContainer .d-flex');
  rows.forEach((row, newIndex) => {
    const input = row.querySelector('input');
    input.name = `workHistoryDetails.allWorkHistoryData[${currentJobIndex}].workDoneSentences[${newIndex}]`;
  });
}

// Save button handler
document.getElementById('saveWorkDone').addEventListener('click', () => {
  reindexWorkDoneRows(); // Ensure indexes are correct before closing
  modal.hide();
});

4. Backend Setup for Proper Binding

Make sure your Java model classes initialize empty lists to avoid null binding errors:

public class WorkHistoryDetails {
    private List<Job> allWorkHistoryData = new ArrayList<>();
    // Getters and setters
}

public class Job {
    private String companyName;
    private String city;
    private String jobTitle;
    private boolean currentJob;
    private String startDate;
    private String endDate;
    // Initialize empty list to let Spring add new items
    private List<String> workDoneSentences = new ArrayList<>();
    
    // Getters and setters for all fields
}

In your controller, initialize the model with empty objects to ensure Thymeleaf can render the form correctly:

@GetMapping("/work-history")
public String showWorkHistoryForm(Model model) {
    WorkHistoryDetails details = new WorkHistoryDetails();
    // Add an initial empty job row (optional, but improves UX)
    details.getAllWorkHistoryData().add(new Job());
    model.addAttribute("workHistoryDetails", details);
    return "work-history-form";
}

@PostMapping("/submit-work-history")
public String submitWorkHistory(@ModelAttribute WorkHistoryDetails workHistoryDetails) {
    // Process your data here
    return "success-page";
}

Key Binding Notes
  • Spring MVC relies on the name attribute format [listIndex].[property].[subListIndex] to bind nested lists correctly. The JavaScript ensures this format stays valid even when rows are added/removed.
  • Always initialize empty lists in your model classes—Spring can't add elements to a null list.
  • The #json.toJson Thymeleaf utility safely converts the list to a JSON string for the frontend to parse.

内容的提问来源于stack exchange,提问作者jetty

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.15 04:57:41