Repository: NationalCentreTruthReconciliation/Secure-Record-Transfer
Pull request #582: Modal to Delete In-Progress Submissions
Author: login: yenaing-oo
Created: 2025-05-20T14:20:15Z | Merged: 2025-05-21T19:44:42Z

Closes #573 

This PR adds the ability to delete an in-progress submission directly from the Profile page, by using a modal to ask the user for confirmation, instead of opening up a new page to do that. I have added [Toasts](https://daisyui.com/components/toast/) to notify users of a successful or failed deletion. All UI changes are limited to the Profile page currently.

Change notes:
- Pagination view now includes validation (see https://github.com/NationalCentreTruthReconciliation/Secure-Record-Transfer/pull/582/commits/ee0a40662b14ee4a98508e46199c67e285c97c9a)
- Added a view and corresponding template to render a modal to delete an IP submission
- Scroll position on profile page is no longer saved
- Last active tab for tables is now saved and stored
- Removes modal on Submission Group Detail page

Linked issue #573: Modal to Delete In-Progress Submissions
Similar to https://github.com/NationalCentreTruthReconciliation/Secure-Record-Transfer/issues/572, a [DaisyUI modal](https://daisyui.com/components/modal/) should be used to delete in-progress submissions from the Profile page. 

The endpoint and corresponding views to delete an in-progress submission already exists (see `submission/in-progress/<uuid:uuid>/` in `urls.py` and `views.pre_submission.DeleteInProgressSubmission`), so this issue deals with the UI only.

Dependent on: https://github.com/NationalCentreTruthReconciliation/Secure-Record-Transfer/issues/536

Diff:
diff --git a/app/recordtransfer/models.py b/app/recordtransfer/models.py
index 7045c3412..bc220e425 100644
--- a/app/recordtransfer/models.py
+++ b/app/recordtransfer/models.py
@@ -1029,6 +1029,10 @@ def get_resume_url(self) -> str:
         """Get the URL to access and resume the in-progress submission."""
         return f"{reverse('recordtransfer:submit')}?resume={self.uuid}"
 
+    def get_delete_url(self) -> str:
+        """Get the URL to delete this in-progress submission."""
+        return reverse("recordtransfer:in_progress_submission", kwargs={"uuid": self.uuid})
+
     def reset_reminder_email_sent(self) -> None:
         """Reset the reminder email flag to False, if it isn't already False."""
         if self.reminder_email_sent:
diff --git a/app/recordtransfer/static/recordtransfer/css/base/modal.css b/app/recordtransfer/static/recordtransfer/css/submission_form/modal.css
similarity index 100%
rename from app/recordtransfer/static/recordtransfer/css/base/modal.css
rename to app/recordtransfer/static/recordtransfer/css/submission_form/modal.css
diff --git a/app/recordtransfer/static/recordtransfer/js/confirm_modal/confirm_modal.js b/app/recordtransfer/static/recordtransfer/js/confirm_modal/confirm_modal.js
deleted file mode 100644
index 93616fd31..000000000
--- a/app/recordtransfer/static/recordtransfer/js/confirm_modal/confirm_modal.js
+++ /dev/null
@@ -1,34 +0,0 @@
-
-/**
- * Sets up a modal that asks a user to confirm an action before executing it.
- * @param {HTMLElement} triggerElement - The element that will trigger the confirmation modal to
- * open when clicked.
- * @param {Function} confirmCallback - The callback function to execute when the user confirms the
- * action.
- */
-export function setupConfirmModal(triggerElement, confirmCallback) {
-    const elements = {
-        modal: document.getElementById("confirm-modal"),
-        closeButton: document.getElementById("close-modal-button"),
-        confirmButton: document.getElementById("confirm-button"),
-        cancelButton: document.getElementById("cancel-button"),
-    };
-
-    const hideModal = () => elements.modal.classList.remove("visible");
-    const showModal = () => elements.modal.classList.add("visible");
-
-    // Show modal when triggerModalElement is clicked on
-    triggerElement.addEventListener("click", (event) => {
-        event.preventDefault();
-        showModal();
-    });
-
-    elements.confirmButton.addEventListener("click", (event) => {
-        event.preventDefault();
-        confirmCallback();
-        hideModal();
-    });
-
-    elements.closeButton.addEventListener("click", hideModal);
-    elements.cancelButton.addEventListener("click", hideModal);
-}
diff --git a/app/recordtransfer/static/recordtransfer/js/profile/delete_in_progress.js b/app/recordtransfer/static/recordtransfer/js/profile/delete_in_progress.js
deleted file mode 100644
index 55400094f..000000000
--- a/app/recordtransfer/static/recordtransfer/js/profile/delete_in_progress.js
+++ /dev/null
@@ -1,25 +0,0 @@
-document.addEventListener("DOMContentLoaded", () => {
-    const contextElement = document.getElementById("py_context_in_progress");
-
-    if (!contextElement) {
-        return;
-    }
-
-    const context = JSON.parse(contextElement.textContent);
-    
-    const okButton = document.getElementById("ok-btn");
-
-    okButton.addEventListener("click", () => {
-        fetch(context["DELETE_URL"], {
-            method: "DELETE",
-            headers: {
-                "X-CSRFToken": document.querySelector("[name=csrfmiddlewaretoken]").value
-            }
-        })
-            .then(response => {
-                if (response.redirected) {
-                    window.location.href = response.url;
-                }
-            });
-    });
-});
\ No newline at end of file
diff --git a/app/recordtransfer/static/recordtransfer/js/profile/delete_ip_submission.js b/app/recordtransfer/static/recordtransfer/js/profile/delete_ip_submission.js
new file mode 100644
index 000000000..644e4870c
--- /dev/null
+++ b/app/recordtransfer/static/recordtransfer/js/profile/delete_ip_submission.js
@@ -0,0 +1,79 @@
+/**
+ * Gets the current page number of the table from the active tab
+ * @returns {string|null} - The current page number or null if not found
+ */
+function getCurrentTablePage() {
+    // Find the active tab
+    const activeTabInput = document.querySelector(
+        ".tabs input[name=\"profile_tabs\"]:checked"
+    );
+    const activeTabContent = activeTabInput
+        ? activeTabInput.closest(".tab").nextElementSibling
+        : null;
+
+    // Find the page-info-btn within the active tab content
+    if (activeTabContent) {
+        const pageInfoBtn = activeTabContent.querySelector(".page-info-btn");
+        if (pageInfoBtn) {
+            return pageInfoBtn.getAttribute("data-current-page");
+        }
+    }
+    return null;
+}
+
+/**
+ * Adds a query parameter to a URL.
+ * @param {string} url - The base URL.
+ * @param {string} paramName - The query parameter name.
+ * @param {string|null} paramValue - The query parameter value.
+ * @returns {string} - The URL with the query parameter appended if applicable.
+ */
+function addQueryParam(url, paramName, paramValue) {
+    if (paramName && paramValue) {
+        const separator = url.includes("?") ? "&" : "?";
+        return `${url}${separator}${paramName}=${encodeURIComponent(paramValue)}`;
+    }
+    return url;
+}
+
+/**
+ * Handles the UI and table refresh after a delete request for an in-progress submission.
+ *
+ * Closes the delete confirmation modal and, if the request was successful,
+ * refreshes the in-progress submissions table via HTMX AJAX.
+ * @param {CustomEvent} e - The event object containing the request result in
+ * `e.detail.successful`.
+ * @param {object} context - Context object with URLs and element IDs for table refresh.
+ * @param {string} context.IN_PROGRESS_SUBMISSION_TABLE_URL - The URL to refresh the in-progress
+ * submissions table.
+ * @param {string} context.PAGINATE_QUERY_NAME - The query parameter name for pagination
+ * (e.g., "p").
+ * @param {string} context.ID_IN_PROGRESS_SUBMISSION_TABLE - The DOM element ID of the in-progress
+ * submissions table to update.
+ */
+export const handleDeleteAfterRequest = (e, context) => {
+    // Close the modal
+    const modal = document.getElementById("delete_in_progress_submission_modal");
+    if (modal) {
+        modal.close();
+    }
+
+    // If the request was successful, refresh the table
+    if (e.detail.successful) {
+        // Get base refresh URL
+        const refreshUrl = context["IN_PROGRESS_SUBMISSION_TABLE_URL"];
+        const paginateQueryName = context["PAGINATE_QUERY_NAME"];
+
+        // Get the current page number
+        const currentPage = getCurrentTablePage();
+
+        const finalUrl = addQueryParam(refreshUrl, paginateQueryName, currentPage);
+
+        window.htmx.ajax("GET",
+            finalUrl,
+            {
+                target: "#" + context["ID_IN_PROGRESS_SUBMISSION_TABLE"],
+                swap: "innerHTML"
+            });
+    }
+};
diff --git a/app/recordtransfer/static/recordtransfer/js/profile/index.js b/app/recordtransfer/static/recordtransfer/js/profile/index.js
new file mode 100644
index 000000000..e9be38eb1
--- /dev/null
+++ b/app/recordtransfer/static/recordtransfer/js/profile/index.js
@@ -0,0 +1,33 @@
+import { handleDeleteAfterRequest } from "./delete_ip_submission.js";
+import { setupProfileForm } from "./profileForm.js";
+import { initTabListeners, restoreTab } from "./tab.js";
+import { setupToastNotifications } from "./toast.js";
+
+
+/**
+ * Main initialization function to set up all profile-related functionality
+ */
+function initialize() {
+    let context = null;
+    const contextElement = document.getElementById("py_context_user_profile");
+
+    if (contextElement) {
+        context = JSON.parse(contextElement.textContent);
+        setupProfileForm(context);
+    }
+
+    initTabListeners();
+    restoreTab();
+    setupToastNotifications();
+
+    // Wrapper function that provides context to handleDeleteAfterRequest
+    window.handleDeleteAfterRequest = (e) => {
+        if (!context) {
+            console.error("Context not available for handleDeleteAfterRequest");
+            return;
+        }
+        return handleDeleteAfterRequest(e, context);
+    };
+}
+
+document.addEventListener("DOMContentLoaded", initialize);
\ No newline at end of file
diff --git a/app/recordtransfer/static/recordtransfer/js/profile/profile.js b/app/recordtransfer/static/recordtransfer/js/profile/profile.js
deleted file mode 100644
index 766d6554d..000000000
--- a/app/recordtransfer/static/recordtransfer/js/profile/profile.js
+++ /dev/null
@@ -1,67 +0,0 @@
-document.addEventListener("DOMContentLoaded", () => {
-    // Context passed from template to JS
-    const userProfileContextElement = document.getElementById("py_context_user_profile");
-
-    if (!userProfileContextElement) {
-        return;
-    }
-
-    const userProfileContext = JSON.parse(userProfileContextElement.textContent);
-
-    const firstName = document.getElementById(userProfileContext["ID_FIRST_NAME"]);
-    const lastName = document.getElementById(userProfileContext["ID_LAST_NAME"]);
-    const getsNotificationEmails = document.getElementById(
-        userProfileContext["ID_GETS_NOTIFICATION_EMAILS"]
-    );
-    const currentPassword = document.getElementById(userProfileContext["ID_CURRENT_PASSWORD"]);
-    const newPassword = document.getElementById(userProfileContext["ID_NEW_PASSWORD"]);
-    const confirmNewPassword = document.getElementById(
-        userProfileContext["ID_CONFIRM_NEW_PASSWORD"]
-    );
-
-    const inputFields = [
-        firstName,
-        lastName,
-        getsNotificationEmails,
-        currentPassword,
-        newPassword,
-        confirmNewPassword
-    ];
-
-    const saveButton = document.getElementById("id_save_button");
-
-    const initialValues = {
-        firstName: firstName.value,
-        lastName: lastName.value,
-        getsNotificationEmails: getsNotificationEmails.checked,
-    };
-
-    const checkForChanges = () => {
-        const hasChanged = firstName.value !== initialValues.firstName ||
-            lastName.value !== initialValues.lastName ||
-            getsNotificationEmails.checked !== initialValues.getsNotificationEmails ||
-            (currentPassword.value && newPassword.value && confirmNewPassword.value);
-
-        saveButton.disabled = !hasChanged;
-    };
-
-    inputFields.forEach(input => {
-        input.addEventListener(input.type === "checkbox" ? "change" : "input", checkForChanges);
-    });
-
-    checkForChanges();
-
-    // Save scroll position before page unload
-    window.addEventListener("beforeunload", () => {
-        localStorage.setItem("scrollPosition", window.scrollY);
-    });
-
-    // Restore scroll position after page load
-    window.addEventListener("load", () => {
-        const scrollPosition = localStorage.getItem("scrollPosition");
-        if (scrollPosition) {
-            window.scrollTo(0, parseInt(scrollPosition, 10));
-            localStorage.removeItem("scrollPosition");
-        }
-    });
-});
diff --git a/app/recordtransfer/static/recordtransfer/js/profile/profileForm.js b/app/recordtransfer/static/recordtransfer/js/profile/profileForm.js
new file mode 100644
index 000000000..439365b3a
--- /dev/null
+++ b/app/recordtransfer/static/recordtransfer/js/profile/profileForm.js
@@ -0,0 +1,57 @@
+/**
+ * Sets up the profile form by tracking changes to input fields and enabling the save button when
+ * changes are detected.
+ * @param {object} context - An object containing DOM element IDs for the profile form fields.
+ * @param {string} context.ID_FIRST_NAME - The ID of the first name input field.
+ * @param {string} context.ID_LAST_NAME - The ID of the last name input field.
+ * @param {string} context.ID_GETS_NOTIFICATION_EMAILS - The ID of the notification emails
+ * checkbox.
+ * @param {string} context.ID_CURRENT_PASSWORD - The ID of the current password input field.
+ * @param {string} context.ID_NEW_PASSWORD - The ID of the new password input field.
+ * @param {string} context.ID_CONFIRM_NEW_PASSWORD - The ID of the confirm new password input
+ * field.
+ */
+export const setupProfileForm = (context) => {
+    const firstName = document.getElementById(context["ID_FIRST_NAME"]);
+    const lastName = document.getElementById(context["ID_LAST_NAME"]);
+    const getsNotificationEmails = document.getElementById(
+        context["ID_GETS_NOTIFICATION_EMAILS"]
+    );
+    const currentPassword = document.getElementById(context["ID_CURRENT_PASSWORD"]);
+    const newPassword = document.getElementById(context["ID_NEW_PASSWORD"]);
+    const confirmNewPassword = document.getElementById(
+        context["ID_CONFIRM_NEW_PASSWORD"]
+    );
+
+    const inputFields = [
+        firstName,
+        lastName,
+        getsNotificationEmails,
+        currentPassword,
+        newPassword,
+        confirmNewPassword
+    ];
+
+    const saveButton = document.getElementById("id_save_button");
+
+    const initialValues = {
+        firstName: firstName.value,
+        lastName: lastName.value,
+        getsNotificationEmails: getsNotificationEmails.checked,
+    };
+
+    const checkForChanges = () => {
+        const hasChanged = firstName.value !== initialValues.firstName ||
+            lastName.value !== initialValues.lastName ||
+            getsNotificationEmails.checked !== initialValues.getsNotificationEmails ||
+            (currentPassword.value && newPassword.value && confirmNewPassword.value);
+
+        saveButton.disabled = !hasChanged;
+    };
+
+    inputFields.forEach(input => {
+        input.addEventListener(input.type === "checkbox" ? "change" : "input", checkForChanges);
+    });
+
+    checkForChanges();
+};
diff --git a/app/recordtransfer/static/recordtransfer/js/profile/tab.js b/app/recordtransfer/static/recordtransfer/js/profile/tab.js
new file mode 100644
index 000000000..fd9ef5140
--- /dev/null
+++ b/app/recordtransfer/static/recordtransfer/js/profile/tab.js
@@ -0,0 +1,35 @@
+/**
+ * Restores the previously active profile tab based on the value stored in localStorage.
+ */
+export const restoreTab = () => {
+    const storedTab = localStorage.getItem("activeProfileTab");
+    if (storedTab) {
+        const tabInputs = document.querySelectorAll(".tabs input[name='profile_tabs']");
+        const tabIndex = Number(storedTab);
+
+        const isValidIndex =
+            Number.isInteger(tabIndex) &&
+            tabIndex >= 0 &&
+            tabIndex < tabInputs.length;
+
+        if (isValidIndex) {
+            tabInputs[tabIndex].checked = true;
+        }
+    }
+};
+
+
+/**
+ * Initializes event listeners for profile tab inputs.
+ * Stores the index of the active tab in localStorage when changed.
+ */
+export const initTabListeners = () => {
+    const tabs = document.querySelectorAll(".tabs input[name='profile_tabs']");
+    tabs.forEach((tab, index) => {
+        tab.addEventListener("change", () => {
+            if (tab.checked) {
+                localStorage.setItem("activeProfileTab", index.toString());
+            }
+        });
+    });
+};
\ No newline at end of file
diff --git a/app/recordtransfer/static/recordtransfer/js/profile/toast.js b/app/recordtransfer/static/recordtransfer/js/profile/toast.js
new file mode 100644
index 000000000..c42de1640
--- /dev/null
+++ b/app/recordtransfer/static/recordtransfer/js/profile/toast.js
@@ -0,0 +1,123 @@
+/**
+ * Utility to show toast notifications.
+ */
+
+const TOAST_CONTAINER_ID = "toast-container";
+
+/**
+ * Displays a toast notification with the specified type and message.
+ * @param {string} type - The type of toast notification ('success', 'error', 'info', 'warning').
+ * @param {string} message - The message to display in the toast notification.
+ */
+function showToast(type, message) {
+    // Map type to alert class
+    const typeClassMap = {
+        success: "alert-success",
+        error: "alert-error",
+        info: "alert-info",
+        warning: "alert-warning"
+    };
+
+    // Map type to icon
+    const typeIconMap = {
+        success: "<i class=\"fas fa-check-circle\"></i>",
+        error: "<i class=\"fas fa-exclamation-circle\"></i>",
+        warning: "<i class=\"fas fa-exclamation-triangle\"></i>",
+        info: "<i class=\"fas fa-info-circle\"></i>"
+    };
+
+    const alertClass = typeClassMap[type] || "alert-info";
+    const iconHtml = typeIconMap[type] || typeIconMap.info;
+
+    // Find or create the toast container
+    let toastContainer = document.getElementById(TOAST_CONTAINER_ID);
+    if (!toastContainer) {
+        toastContainer = document.createElement("div");
+        toastContainer.className = "toast toast-top toast-center";
+        toastContainer.id = TOAST_CONTAINER_ID;
+        document.body.appendChild(toastContainer);
+    }
+
+    // Create the alert element
+    const alertDiv = document.createElement("div");
+    alertDiv.className = `alert ${alertClass}`;
+
+    // Add icon
+    const iconSpan = document.createElement("span");
+    iconSpan.innerHTML = iconHtml;
+    iconSpan.className = "mr-2";
+    alertDiv.appendChild(iconSpan);
+
+    // Add message
+    const messageSpan = document.createElement("span");
+    messageSpan.textContent = message;
+    alertDiv.appendChild(messageSpan);
+
+    // Add to DOM
+    toastContainer.appendChild(alertDiv);
+
+    // Fade out and remove after 5 seconds
+    setTimeout(() => {
+        alertDiv.style.transition = "opacity 0.5s";
+        alertDiv.style.opacity = "0";
+        setTimeout(() => {
+            alertDiv.remove();
+            if (toastContainer.children.length === 0) {
+                toastContainer.remove();
+            }
+        }, 500);
+    }, 5000);
+}
+
+// Convenience functions
+/**
+ * Shows a success toast notification.
+ * @param {string} message - The message to display in the toast notification.
+ */
+function showSuccessToast(message) {
+    showToast("success", message);
+}
+/**
+ * Shows an error toast notification.
+ * @param {string} message - The message to display in the toast notification.
+ */
+function showErrorToast(message) {
+    showToast("error", message);
+}
+/**
+ * Shows an info toast notification.
+ * @param {string} message - The message to display in the toast notification.
+ */
+function showInfoToast(message) {
+    showToast("info", message);
+}
+/**
+ * Shows a warning toast notification.
+ * @param {string} message - The message to display in the toast notification.
+ */
+function showWarningToast(message) {
+    showToast("warning", message);
+}
+
+/**
+ * Registers HTMX event listeners to show toast notifications for custom events.
+ */
+export const setupToastNotifications = () => {
+    if (window.htmx) {
+        window.htmx.on("showSuccess", (event) => {
+            showSuccessToast(event.detail.value);
+        });
+
+        window.htmx.on("showError", (event) => {
+            showErrorToast(event.detail.value);
+        });
+
+        window.htmx.on("showInfo", (event) => {
+            showInfoToast(event.detail.value);
+        });
+
+        window.htmx.on("showWarning", (event) => {
+            showWarningToast(event.detail.value);
+        });
+    }
+};
\ No newline at end of file
diff --git a/app/recordtransfer/static/recordtransfer/js/submission_form/index.js b/app/recordtransfer/static/recordtransfer/js/submission_form/index.js
index b610d6595..f90262915 100644
--- a/app/recordtransfer/static/recordtransfer/js/submission_form/index.js
+++ b/app/recordtransfer/static/recordtransfer/js/submission_form/index.js
@@ -1,6 +1,7 @@
 /* global singleCaptchaFn */
 import "../../css/submission_form/review_step.css";
 import "../../css/submission_form/uppy.css";
+import "../../css/submission_form/modal.css";
 
 import { setupContactInfoForm } from "./contactInfo";
 import { setupOtherIdentifiersForm } from "./otherIdentifiers";
diff --git a/app/recordtransfer/static/recordtransfer/js/submission_group/submission_group_form.js b/app/recordtransfer/static/recordtransfer/js/submission_group/submission_group_form.js
index cbacc323b..01d95adb3 100644
--- a/app/recordtransfer/static/recordtransfer/js/submission_group/submission_group_form.js
+++ b/app/recordtransfer/static/recordtransfer/js/submission_group/submission_group_form.js
@@ -1,5 +1,3 @@
-import { setupConfirmModal } from "../confirm_modal/confirm_modal";
-
 document.addEventListener("DOMContentLoaded", function () {
     const contextElement = document.getElementById("py_context_submission_group");
 
@@ -39,22 +37,18 @@ document.addEventListener("DOMContentLoaded", function () {
     });
 
     const deleteButton = document.getElementById("id_delete_group_button");
-    if (deleteButton) {
-        const handleDeleteConfirm = function() {
-            fetch(context["DELETE_URL"], {
-                method: "DELETE",
-                headers: {
-                    "X-CSRFToken": document.querySelector("[name=csrfmiddlewaretoken]").value
-                }
-            }).then(response => {
-                if (response.redirected) {
-                    window.location.href = response.url;
-                }
-            });
-        };
-        
-        setupConfirmModal(deleteButton, handleDeleteConfirm);
-    }
+    deleteButton.addEventListener("click", () => {
+        fetch(context["DELETE_URL"], {
+            method: "DELETE",
+            headers: {
+                "X-CSRFToken": document.querySelector("[name=csrfmiddlewaretoken]").value
+            }
+        }).then(response => {
+            if (response.redirected) {
+                window.location.href = response.url;
+            }
+        });
+    });
 
     checkForChanges();
 });
diff --git a/app/recordtransfer/templates/includes/base_modal.html b/app/recordtransfer/templates/includes/base_modal.html
new file mode 100644
index 000000000..b9370809b
--- /dev/null
+++ b/app/recordtransfer/templates/includes/base_modal.html
@@ -0,0 +1,15 @@
+{% load i18n %}
+{% load static %}
+<dialog id={% block modal_id %}{% endblock modal_id %} class="modal">
+    <div class="modal-box">
+        <form method="dialog">
+            <button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
+        </form>
+        <h3 class="text-lg font-bold mb-5">
+            {% block modal_title %}
+            {% endblock modal_title %}
+        </h3>
+        {% block modal_content %}
+        {% endblock modal_content %}
+    </div>
+</dialog>
diff --git a/app/recordtransfer/templates/includes/delete_in_progress_submission_modal.html b/app/recordtransfer/templates/includes/delete_in_progress_submission_modal.html
new file mode 100644
index 000000000..a00a73b51
--- /dev/null
+++ b/app/recordtransfer/templates/includes/delete_in_progress_submission_modal.html
@@ -0,0 +1,30 @@
+{% extends "includes/base_modal.html" %}
+{% load i18n %}
+{% block modal_id %}
+    delete_in_progress_submission_modal
+{% endblock modal_id %}
+{% block modal_title %}
+    {% trans "Are you sure you want to delete this in-progress submission?" %}
+{% endblock modal_title %}
+{% block modal_content %}
+    <p>
+        {% if in_progress.title %}
+            {% blocktrans with title=in_progress.title|escape date=in_progress.last_updated|date:"F j, Y g:ia"|escape %}
+                Do you wish to delete the in-progress submission from {{ date }} titled "{{ title }}"?
+            {% endblocktrans %}
+        {% else %}
+            {% blocktrans with date=in_progress.last_updated|date:"F j, Y g:ia"|escape %}
+                Do you wish to delete the in-progress submission from {{ date }}?
+            {% endblocktrans %}
+        {% endif %}
+    </p>
+    <form method="dialog">
+        <div class="modal-action">
+            <button class="btn">{% trans "Cancel" %}</button>
+            <button id="confirm_delete_ip_btn"
+                    hx-delete="{{ in_progress.get_delete_url }}"
+                    hx-on::after-request="handleDeleteAfterRequest(event)"
+                    class="btn btn-primary">{% trans "Yes" %}</button>
+        </div>
+    </form>
+{% endblock modal_content %}
diff --git a/app/recordtransfer/templates/includes/in_progress_submission_table.html b/app/recordtransfer/templates/includes/in_progress_submission_table.html
index 0212422e6..6eb69fd71 100644
--- a/app/recordtransfer/templates/includes/in_progress_submission_table.html
+++ b/app/recordtransfer/templates/includes/in_progress_submission_table.html
@@ -41,7 +41,10 @@
                             <i class="fas fa-play"></i>
                         </a>
                     {% endif %}
-                    <a href="{% url 'recordtransfer:delete_in_progress' uuid=in_progress.uuid %}"
+                    <a hx-get="{% url "recordtransfer:in_progress_submission" uuid=in_progress.uuid %}"
+                       hx-target="#modal-container"
+                       hx-swap="innerHTML"
+                       hx-trigger="click"
                        class="link link-error tooltip"
                        data-tip="{% trans "Delete" %}">
                         <i class="fas fa-trash-alt"></i>
diff --git a/app/recordtransfer/templates/includes/pagination.html b/app/recordtransfer/templates/includes/pagination.html
index 0cf5c6c15..258fd0704 100644
--- a/app/recordtransfer/templates/includes/pagination.html
+++ b/app/recordtransfer/templates/includes/pagination.html
@@ -8,7 +8,7 @@
     {% else %}
         <button class="join-item btn btn-xs btn-disabled p-3" aria-disabled="true">«</button>
     {% endif %}
-    <button class="join-item btn btn-xs p-3 page-info-btn">
+    <button class="join-item btn btn-xs p-3 page-info-btn" data-current-page={{ page.number }}>
         {% blocktrans with curr_page=page.number total_pages=page.paginator.num_pages %}
             Page {{ curr_page }} of {{ total_pages }}
         {% endblocktrans %}
diff --git a/app/recordtransfer/templates/recordtransfer/profile.html b/app/recordtransfer/templates/recordtransfer/profile.html
index 70305d73c..73a17fdd8 100644
--- a/app/recordtransfer/templates/recordtransfer/profile.html
+++ b/app/recordtransfer/templates/recordtransfer/profile.html
@@ -82,4 +82,6 @@
             </div>
         </div>
     </div>
+    <div hx-on::after-swap="document.querySelector('.modal').showModal()"
+         id="modal-container"></div>
 {% endblock content %}
diff --git a/app/recordtransfer/tests/unit/test_models.py b/app/recordtransfer/tests/unit/test_models.py
index f9889610f..096be6987 100644
--- a/app/recordtransfer/tests/unit/test_models.py
+++ b/app/recordtransfer/tests/unit/test_models.py
@@ -1066,6 +1066,13 @@ def test_get_resume_url(self) -> None:
         expected_url = f"{reverse('recordtransfer:submit')}?resume={self.in_progress.uuid}"
         self.assertEqual(self.in_progress.get_resume_url(), expected_url)
 
+    def test_get_delete_url(self) -> None:
+        """Test the get_delete_url method."""
+        expected_url = reverse(
+            "recordtransfer:in_progress_submission", kwargs={"uuid": self.in_progress.uuid}
+        )
+        self.assertEqual(self.in_progress.get_delete_url(), expected_url)
+
     def test_reset_reminder_email_sent_flag_true(self) -> None:
         """Test reset_reminder_email_sent method when the flag is True."""
         self.in_progress.reminder_email_sent = True
diff --git a/app/recordtransfer/tests/unit/views/test_pre_submission.py b/app/recordtransfer/tests/unit/views/test_pre_submission.py
index 9e5fc58d7..50a2e5d35 100644
--- a/app/recordtransfer/tests/unit/views/test_pre_submission.py
+++ b/app/recordtransfer/tests/unit/views/test_pre_submission.py
@@ -184,9 +184,7 @@ def test_saving_expirable_in_progress_submission(self) -> None:
                 self.assertEqual(200, response.status_code)
                 # Check that response tells HTMX on client side to redirect to user profile
                 hx_redirect = response.headers.get("HX-Redirect") or ""
-                self.assertEqual(
-                    hx_redirect, reverse("recordtransfer:user_profile")
-                )
+                self.assertEqual(hx_redirect, reverse("recordtransfer:user_profile"))
                 # Follow the redirect to user profile
                 response = self.client.get(hx_redirect, follow=True)
                 self.assertEqual(200, response.status_code)
@@ -217,9 +215,7 @@ def test_saving_unexpirable_in_progress_submission(self) -> None:
                 self.assertEqual(200, response.status_code)
                 # Check that response tells HTMX on client side to redirect to user profile
                 hx_redirect = response.headers.get("HX-Redirect") or ""
-                self.assertEqual(
-                    hx_redirect, reverse("recordtransfer:user_profile")
-                )
+                self.assertEqual(hx_redirect, reverse("recordtransfer:user_profile"))
                 # Follow the redirect to user profile
                 response = self.client.get(hx_redirect, follow=True)
                 self.assertEqual(200, response.status_code)
@@ -249,129 +245,3 @@ def test_resume_expired_in_progress_submission(self, mock_expired: PropertyMock)
         mock_expired.return_value = True
         response = self.client.get(self.url, {"resume": in_progress.uuid}, follow=True)
         self.assertRedirects(response, reverse("recordtransfer:in_progress_submission_expired"))
-
-
-class DeleteInProgressSubmissionTests(TestCase):
-    """Tests for the DeleteInProgressSubmission view."""
-
-    def setUp(self) -> None:
-        """Set up the test case with users and in-progress submissions."""
-        self.user = User.objects.create_user(
-            username="testuser1", email="test@example.com", password="testpassword"
-        )
-        self.other_user = User.objects.create_user(
-            username="testuser2", email="test2@example.com", password="testpassword"
-        )
-
-        # Create upload sessions for each user
-        self.upload_session = UploadSession.new_session(user=cast(User, self.user))
-        self.other_upload_session = UploadSession.new_session(user=cast(User, self.other_user))
-
-        # Create in-progress submissions for each user
-        self.in_progress = InProgressSubmission.objects.create(
-            user=self.user,
-            upload_session=self.upload_session,
-            current_step=SubmissionStep.CONTACT_INFO.value,
-        )
-        self.other_in_progress = InProgressSubmission.objects.create(
-            user=self.other_user,
-            upload_session=self.other_upload_session,
-            current_step=SubmissionStep.CONTACT_INFO.value,
-        )
-
-        self.client.force_login(self.user)
-        self.url = reverse(
-            "recordtransfer:delete_in_progress", kwargs={"uuid": self.in_progress.uuid}
-        )
-
-    def test_get_queryset(self) -> None:
-        """Test that get_queryset only returns submissions belonging to the current user."""
-        response = self.client.get(self.url)
-        self.assertEqual(response.status_code, 200)
-
-        # View should only include the current user's submission
-        view = response.context["view"]
-        queryset = view.get_queryset()
-        self.assertEqual(queryset.count(), 1)
-        self.assertEqual(queryset.first().uuid, self.in_progress.uuid)
-
-        # Other user's submission should not be in the queryset
-        self.assertNotIn(self.other_in_progress, queryset)
-
-    def test_get_object(self) -> None:
-        """Test that get_object returns the correct submission."""
-        response = self.client.get(self.url)
-        self.assertEqual(response.status_code, 200)
-
-        # The object in the context should be the user's in-progress submission
-        self.assertEqual(response.context["in_progress"].uuid, self.in_progress.uuid)
-
-    def test_delete_success(self) -> None:
-        """Test successful deletion of an in-progress submission."""
-        self.assertTrue(InProgressSubmission.objects.filter(uuid=self.in_progress.uuid).exists())
-
-        response = self.client.delete(self.url, follow=True)
-
-        # Check that deletion was successful
-        self.assertFalse(InProgressSubmission.objects.filter(uuid=self.in_progress.uuid).exists())
-
-        # Check that we were redirected to user profile
-        self.assertRedirects(response, reverse("recordtransfer:user_profile"))
-
-        # Check for success message
-        messages = list(response.context["messages"])
-        self.assertEqual(len(messages), 1)
-        self.assertEqual(str(messages[0]), "In-progress submission deleted")
-
-    @patch("recordtransfer.views.pre_submission.InProgressSubmission.delete")
-    def test_delete_error(self, mock_delete: MagicMock) -> None:
-        """Test error handling during deletion."""
-        # Simulate an error during deletion
-        mock_delete.side_effect = Exception("Test error")
-
-        response = self.client.delete(self.url, follow=True)
-
-        # Check that we were redirected to user profile
-        self.assertRedirects(response, reverse("recordtransfer:user_profile"))
-
-        # Check for error message
-        messages = list(response.context["messages"])
-        self.assertEqual(len(messages), 1)
-        self.assertEqual(
-            str(messages[0]), "There was an error deleting the in-progress submission"
-        )
-
-    def test_cannot_delete_other_users_submission(self) -> None:
-        """Test that a user cannot delete another user's in-progress submission."""
-        other_url = reverse(
-            "recordtransfer:delete_in_progress", kwargs={"uuid": self.other_in_progress.uuid}
-        )
-
-        response = self.client.get(other_url)
-        self.assertEqual(response.status_code, 404)
-
-        response = self.client.delete(other_url)
-        self.assertEqual(response.status_code, 404)
-
-        # Verify the other user's submission still exists
-        self.assertTrue(
-            InProgressSubmission.objects.filter(uuid=self.other_in_progress.uuid).exists()
-        )
-
-    def test_login_required(self) -> None:
-        """Test that login is required to access the view."""
-        self.client.logout()
-
-        response = self.client.get(self.url)
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.post(self.url)
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.delete(self.url)
-        self.assertEqual(response.status_code, 302)
-
-    def test_post_method_not_allowed(self) -> None:
-        """Test that POST method is not allowed for this view."""
-        response = self.client.post(self.url)
-        self.assertEqual(response.status_code, 405)
diff --git a/app/recordtransfer/tests/unit/views/test_profile.py b/app/recordtransfer/tests/unit/views/test_profile.py
index 14b0d64e0..0cca1f842 100644
--- a/app/recordtransfer/tests/unit/views/test_profile.py
+++ b/app/recordtransfer/tests/unit/views/test_profile.py
@@ -2,7 +2,7 @@
 import uuid
 from datetime import datetime, timedelta
 from typing import Optional, cast
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
 from zoneinfo import ZoneInfo
 
 from django.conf import settings
@@ -13,6 +13,7 @@
 from freezegun import freeze_time
 
 from recordtransfer.constants import PAGINATE_QUERY_NAME
+from recordtransfer.enums import SubmissionStep
 from recordtransfer.models import (
     InProgressSubmission,
     Submission,
@@ -508,3 +509,160 @@ def test_submission_group_table_pagination(self) -> None:
         self.assertIn("Test Group 2", response.content.decode())
         self.assertNotIn("Test Group 0", response.content.decode())
         self.assertNotIn("Test Group 1", response.content.decode())
+
+
+class TestInProgressSubmission(TestCase):
+    """Tests for the in_progress_submission view."""
+
+    def setUp(self) -> None:
+        """Set up the test case with users and in-progress submissions."""
+        self.user = User.objects.create_user(
+            username="testuser1", email="test@example.com", password="testpassword"
+        )
+        self.other_user = User.objects.create_user(
+            username="testuser2", email="test2@example.com", password="testpassword"
+        )
+
+        # Create upload sessions for each user
+        self.upload_session = UploadSession.new_session(user=cast(User, self.user))
+        self.other_upload_session = UploadSession.new_session(user=cast(User, self.other_user))
+
+        # Create in-progress submissions for each user
+        self.in_progress = InProgressSubmission.objects.create(
+            user=self.user,
+            upload_session=self.upload_session,
+            current_step=SubmissionStep.CONTACT_INFO.value,
+            title="Test Submission 1",
+        )
+        self.other_in_progress = InProgressSubmission.objects.create(
+            user=self.other_user,
+            upload_session=self.other_upload_session,
+            current_step=SubmissionStep.CONTACT_INFO.value,
+            title="Test Submission 2",
+        )
+
+        self.client.force_login(self.user)
+        self.url = reverse(
+            "recordtransfer:in_progress_submission", kwargs={"uuid": self.in_progress.uuid}
+        )
+        self.headers = {
+            "HX-Request": "true",
+        }
+
+    # DELETE tests
+    def test_delete_success(self) -> None:
+        """Test successful deletion of an in-progress submission."""
+        self.assertTrue(InProgressSubmission.objects.filter(uuid=self.in_progress.uuid).exists())
+
+        response = self.client.delete(self.url, headers=self.headers)
+
+        # Check for proper status code
+        self.assertEqual(response.status_code, 204)
+
+        # Check that HTMX showSuccess event is included in the response
+        self.assertIn("HX-Trigger", response.headers)
+        self.assertIn("showSuccess", response.headers["HX-Trigger"])
+
+        # Check that deletion was successful
+        self.assertFalse(InProgressSubmission.objects.filter(uuid=self.in_progress.uuid).exists())
+
+    @patch("recordtransfer.views.profile.InProgressSubmission.delete")
+    def test_delete_error(self, mock_delete: MagicMock) -> None:
+        """Test that an error during deletion returns a 500 status code."""
+        mock_delete.side_effect = Exception("Deletion error")
+
+        response = self.client.delete(self.url, headers=self.headers)
+
+        # Check for proper status code
+        self.assertEqual(response.status_code, 500)
+
+        # Check that HTMX showError event is included in the response
+        self.assertIn("HX-Trigger", response.headers)
+        self.assertIn("showError", response.headers["HX-Trigger"])
+
+        # Check that the in-progress submission still exists
+        self.assertTrue(InProgressSubmission.objects.filter(uuid=self.in_progress.uuid).exists())
+
+    def test_non_htmx_delete_fails(self) -> None:
+        """Test that a non-HTMX request fails with a 400 status code."""
+        response = self.client.delete(self.url)
+
+        # Check for proper status code
+        self.assertEqual(response.status_code, 400)
+
+        # Check that the in-progress submission still exists
+        self.assertTrue(InProgressSubmission.objects.filter(uuid=self.in_progress.uuid).exists())
+
+    def test_cannot_delete_other_users_submission(self) -> None:
+        """Test that a user cannot delete another user's in-progress submission."""
+        other_url = reverse(
+            "recordtransfer:in_progress_submission", kwargs={"uuid": self.other_in_progress.uuid}
+        )
+
+        response = self.client.delete(other_url, headers=self.headers)
+        self.assertEqual(response.status_code, 404)
+
+        # Verify the other user's submission still exists
+        self.assertTrue(
+            InProgressSubmission.objects.filter(uuid=self.other_in_progress.uuid).exists()
+        )
+
+    def test_login_required(self) -> None:
+        """Test that login is required to access the view."""
+        self.client.logout()
+
+        response = self.client.get(self.url, headers=self.headers)
+        self.assertEqual(response.status_code, 302)
+
+        response = self.client.post(self.url, headers=self.headers)
+        self.assertEqual(response.status_code, 302)
+
+        response = self.client.delete(self.url, headers=self.headers)
+        self.assertEqual(response.status_code, 302)
+
+    def test_post_method_not_allowed(self) -> None:
+        """Test that POST method is not allowed for this view."""
+        response = self.client.post(self.url, headers=self.headers)
+        self.assertEqual(response.status_code, 405)
+
+    # GET tests (modal retrieval)
+    def test_get_modal_success(self) -> None:
+        """Test successful retrieval of the delete modal for an in-progress submission."""
+        response = self.client.get(self.url, headers=self.headers)
+
+        # Check for proper status code
+        self.assertEqual(response.status_code, 200)
+
+        # Check that the correct template is used
+        self.assertTemplateUsed(response, "includes/delete_in_progress_submission_modal.html")
+
+        # Check that the context contains the required variables
+        self.assertEqual(response.context["in_progress"], self.in_progress)
+
+        # Check that the in-progress submission's title is included in the response
+        self.assertContains(response, "Test Submission 1")
+
+    def test_non_htmx_get_fails(self) -> None:
+        """Test that a non-HTMX request fails with a 400 status code."""
+        response = self.client.get(self.url)
+
+        # Check for proper status code
+        self.assertEqual(response.status_code, 400)
+
+    def test_cannot_get_other_users_submission_modal(self) -> None:
+        """Test that a user cannot get the modal for another user's in-progress submission."""
+        other_url = reverse(
+            "recordtransfer:in_progress_submission", kwargs={"uuid": self.other_in_progress.uuid}
+        )
+
+        response = self.client.get(other_url, headers=self.headers)
+        self.assertEqual(response.status_code, 404)
+
+    def test_nonexistent_submission(self) -> None:
+        """Test that requesting a modal for a nonexistent submission returns 404."""
+        nonexistent_url = reverse(
+            "recordtransfer:in_progress_submission", kwargs={"uuid": str(uuid.uuid4())}
+        )
+
+        response = self.client.get(nonexistent_url, headers=self.headers)
+        self.assertEqual(response.status_code, 404)
diff --git a/app/recordtransfer/urls.py b/app/recordtransfer/urls.py
index a01808584..03cf24dfc 100644
--- a/app/recordtransfer/urls.py
+++ b/app/recordtransfer/urls.py
@@ -38,8 +38,8 @@
     ),
     path(
         "submission/in-progress/<uuid:uuid>/",
-        login_required(views.pre_submission.DeleteInProgressSubmission.as_view()),
-        name="delete_in_progress",
+        login_required(views.profile.in_progress_submission),
+        name="in_progress_submission",
     ),
     path("about/", views.home.About.as_view(), name="about"),
     path("help/", views.home.Help.as_view(), name="help"),  # Add this line for the Help page
diff --git a/app/recordtransfer/views/pre_submission.py b/app/recordtransfer/views/pre_submission.py
index a1e8e979a..342c193ab 100644
--- a/app/recordtransfer/views/pre_submission.py
+++ b/app/recordtransfer/views/pre_submission.py
@@ -10,7 +10,6 @@
 from caais.models import RightsType, SourceRole, SourceType
 from django.conf import settings
 from django.contrib import messages
-from django.db.models import QuerySet
 from django.forms import (
     BaseForm,
     BaseFormSet,
@@ -20,11 +19,11 @@
     formset_factory,
 )
 from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect, QueryDict
-from django.shortcuts import get_object_or_404, redirect
-from django.urls import reverse, reverse_lazy
+from django.shortcuts import redirect
+from django.urls import reverse
 from django.utils import timezone
 from django.utils.translation import gettext
-from django.views.generic import DeleteView, TemplateView
+from django.views.generic import TemplateView
 from django_htmx.http import HttpResponseClientRedirect
 from formtools.wizard.views import SessionWizardView
 
@@ -715,59 +714,3 @@ def done(self, form_list: list[BaseForm], **kwargs) -> HttpResponseRedirect:
             send_submission_creation_failure.delay(form_data, cast(User, self.request.user))
 
             return HttpResponseRedirect(reverse("recordtransfer:system_error"))
-
-
-class DeleteInProgressSubmission(DeleteView):
-    """View to handle the deletion of an in-progress submission."""
-
-    template_name = "recordtransfer/in_progress_submission_delete.html"
-    model = InProgressSubmission
-    context_object_name = "in_progress"
-    success_url = reverse_lazy("recordtransfer:user_profile")
-    success_message = gettext("In-progress submission deleted")
-    error_message = gettext("There was an error deleting the in-progress submission")
-
-    def __init__(self, **kwargs) -> None:
-        super().__init__(**kwargs)
-        self.http_method_names = ["get", "delete"]
-
-    def get_queryset(self) -> QuerySet[InProgressSubmission]:
-        """Filter submissions to only show the current user's."""
-        return super().get_queryset().filter(user=self.request.user)
-
-    def get_object(
-        self, queryset: Optional[QuerySet[InProgressSubmission]] = None
-    ) -> InProgressSubmission:
-        """Retrieve the InProgressSubmission object based on the UUID in the URL."""
-        if queryset is None:
-            queryset = self.get_queryset()
-        # Only retrieve an in-progress submission belonging to the current user
-        return get_object_or_404(queryset, uuid=self.kwargs.get("uuid"))
-
-    def get_context_data(self, **kwargs) -> dict[str, Any]:
-        """Retrieve context data for the template."""
-        context = super().get_context_data(**kwargs)
-        context[self.context_object_name] = self.get_object()
-        context["js_context"] = {
-            "DELETE_URL": reverse(
-                "recordtransfer:delete_in_progress",
-                kwargs={"uuid": self.get_object().uuid},
-            )
-        }
-        return context
-
-    def delete(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
-        """Override delete to add success message, and handle any errors."""
-        try:
-            # Ensure the object belongs to the current user
-            self.get_object()
-            response = super().delete(request, *args, **kwargs)
-            messages.success(request, self.success_message)
-            return response
-        except Http404:
-            # Return a 404 response if the object does not exist or does not belong to the user
-            raise
-        except Exception as e:
-            LOGGER.error("Error deleting submission: %s", str(e))
-            messages.error(request, self.error_message)
-            return redirect("recordtransfer:user_profile")
diff --git a/app/recordtransfer/views/profile.py b/app/recordtransfer/views/profile.py
index fadafe97c..2e6b2c61a 100644
--- a/app/recordtransfer/views/profile.py
+++ b/app/recordtransfer/views/profile.py
@@ -8,11 +8,13 @@
 from django.core.paginator import Paginator
 from django.db.models import QuerySet
 from django.forms import BaseModelForm
-from django.http import HttpRequest, HttpResponse
-from django.shortcuts import render
+from django.http import Http404, HttpRequest, HttpResponse
+from django.shortcuts import get_object_or_404, render
 from django.urls import reverse, reverse_lazy
 from django.utils.translation import gettext
+from django.views.decorators.http import require_http_methods
 from django.views.generic import UpdateView
+from django_htmx.http import trigger_client_event
 
 from recordtransfer.constants import (
     ID_CONFIRM_NEW_PASSWORD,
@@ -53,14 +55,20 @@ def get_context_data(self, **kwargs) -> dict[str, Any]:
         context = super().get_context_data(**kwargs)
         context.update(
             {
-                # Form field element IDs
                 "js_context": {
+                    # User profile form
                     "ID_FIRST_NAME": ID_FIRST_NAME,
                     "ID_LAST_NAME": ID_LAST_NAME,
                     "ID_GETS_NOTIFICATION_EMAILS": ID_GETS_NOTIFICATION_EMAILS,
                     "ID_CURRENT_PASSWORD": ID_CURRENT_PASSWORD,
                     "ID_NEW_PASSWORD": ID_NEW_PASSWORD,
                     "ID_CONFIRM_NEW_PASSWORD": ID_CONFIRM_NEW_PASSWORD,
+                    # Tables
+                    "PAGINATE_QUERY_NAME": PAGINATE_QUERY_NAME,
+                    "ID_IN_PROGRESS_SUBMISSION_TABLE": ID_IN_PROGRESS_SUBMISSION_TABLE,
+                    "IN_PROGRESS_SUBMISSION_TABLE_URL": reverse(
+                        "recordtransfer:in_progress_submission_table"
+                    ),
                 },
                 # Table container IDs
                 "ID_SUBMISSION_TABLE": ID_SUBMISSION_TABLE,
@@ -106,6 +114,39 @@ def form_invalid(self, form: BaseModelForm) -> HttpResponse:
         return super().form_invalid(profile_form)
 
 
+@require_http_methods(["GET", "DELETE"])
+def in_progress_submission(request: HttpRequest, uuid: str) -> HttpResponse:
+    """Handle GET (show modal) and DELETE (delete submission) for in-progress submissions. Both
+    requests must be made by HTMX, or else a 400 Error is returned.
+    """
+    if not request.htmx:
+        return HttpResponse(status=400)
+
+    try:
+        in_progress = get_object_or_404(InProgressSubmission, uuid=uuid, user=request.user)
+
+        if request.method == "GET":
+            context = {"in_progress": in_progress}
+            return render(request, "includes/delete_in_progress_submission_modal.html", context)
+
+        # DELETE request
+        in_progress.delete()
+        response = HttpResponse(status=204)
+        return trigger_client_event(
+            response, "showSuccess", {"value": "In-progress submission deleted."}
+        )
+    except Http404:
+        response = HttpResponse(status=404)
+        return trigger_client_event(
+            response, "showError", {"value": "In-progress submission not found."}
+        )
+    except Exception:
+        response = HttpResponse(status=500)
+        return trigger_client_event(
+            response, "showError", {"value": "Failed to delete in-progress submission."}
+        )
+
+
 def _paginated_table_view(
     request: HttpRequest,
     queryset: QuerySet,
@@ -122,6 +163,16 @@ def _paginated_table_view(
     paginator = Paginator(queryset, settings.PAGINATE_BY)
     page_num = request.GET.get(PAGINATE_QUERY_NAME, 1)
 
+    try:
+        page_num = int(page_num)
+    except (TypeError, ValueError):
+        page_num = 1
+
+    if page_num < 1:
+        page_num = 1
+    elif page_num > paginator.num_pages:
+        page_num = paginator.num_pages
+
     data = {
         "page": paginator.get_page(page_num),
         "page_num": page_num,


Reviews:
user: login: danloveg
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-21T18:46:47Z
user: login: yenaing-oo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T19:16:29Z
user: login: yenaing-oo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T19:17:22Z
user: login: yenaing-oo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T19:17:27Z
user: login: danloveg
body: Looks good!

I'm approving this with a caveat: deleting submissions groups from the submission group page is broken now because `setupConfirmModal` was deleted. Since we'll be taking another look at the submission group page in issues #572 and #574 I am not overly worried. I will be marking those as high priority since we'll want that functionality restored before the next release.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-21T19:44:18Z

Review comments:
diff_hunk: @@ -508,3 +509,160 @@ def test_submission_group_table_pagination(self) -> None:
         self.assertIn("Test Group 2", response.content.decode())
         self.assertNotIn("Test Group 0", response.content.decode())
         self.assertNotIn("Test Group 1", response.content.decode())
+
+
+class TestInProgressSubmission(TestCase):
+    """Tests for the in_progress_submission view."""
+
+    def setUp(self) -> None:
+        """Set up the test case with users and in-progress submissions."""
+        self.user = User.objects.create_user(
+            username="testuser1", email="test@example.com", password="testpassword"
+        )
+        self.other_user = User.objects.create_user(
+            username="testuser2", email="test2@example.com", password="testpassword"
+        )
+
+        # Create upload sessions for each user
+        self.upload_session = UploadSession.new_session(user=cast(User, self.user))
+        self.other_upload_session = UploadSession.new_session(user=cast(User, self.other_user))
+
+        # Create in-progress submissions for each user
+        self.in_progress = InProgressSubmission.objects.create(
+            user=self.user,
+            upload_session=self.upload_session,
+            current_step=SubmissionStep.CONTACT_INFO.value,
+            title="Test Submission 1",
+        )
+        self.other_in_progress = InProgressSubmission.objects.create(
+            user=self.other_user,
+            upload_session=self.other_upload_session,
+            current_step=SubmissionStep.CONTACT_INFO.value,
+            title="Test Submission 2",
+        )
+
+        self.client.force_login(self.user)
+        self.url = reverse(
+            "recordtransfer:in_progress_submission", kwargs={"uuid": self.in_progress.uuid}
+        )
+        self.headers = {
+            "HX-Request": "true",
+        }
+
+    # DELETE tests
+    def test_delete_success(self) -> None:
+        """Test successful deletion of an in-progress submission."""
+        self.assertTrue(InProgressSubmission.objects.filter(uuid=self.in_progress.uuid).exists())
+
+        response = self.client.delete(self.url, headers=self.headers)
+
+        # Check for proper status code
+        self.assertEqual(response.status_code, 204)
+
+        # Check that HTMX showSuccess event is included in the response
+        self.assertIn("HX-Trigger", response.headers)
+        self.assertIn("showSuccess", response.headers["HX-Trigger"])
+
+        # Check that deletion was successful
+        self.assertFalse(InProgressSubmission.objects.filter(uuid=self.in_progress.uuid).exists())
+
+    @patch("recordtransfer.views.profile.InProgressSubmission.delete")
+    def test_delete_error(self, mock_delete: MagicMock) -> None:
+        """Test that an error during deletion returns a 500 status code."""
+        mock_delete.side_effect = Exception("Deletion error")
+
+        response = self.client.delete(self.url, headers=self.headers)
+
+        # Check for proper status code
+        self.assertEqual(response.status_code, 500)
+
+        # Check that HTMX showError event is included in the response
+        self.assertIn("HX-Trigger", response.headers)
+        self.assertIn("showError", response.headers["HX-Trigger"])
+
+        # Check that the in-progress submission still exists
+        self.assertTrue(InProgressSubmission.objects.filter(uuid=self.in_progress.uuid).exists())
+
+    def test_non_htmx_delete_fails(self) -> None:
+        """Test that a non-HTMX request fails with a 400 status code."""
+        response = self.client.delete(self.url)
+
+        # Check for proper status code
+        self.assertEqual(response.status_code, 400)
+
+        # Check that the in-progress submission still exists
+        self.assertTrue(InProgressSubmission.objects.filter(uuid=self.in_progress.uuid).exists())
+
+    def test_cannot_delete_other_users_submission(self) -> None:
+        """Test that a user cannot delete another user's in-progress submission."""
+        other_url = reverse(
+            "recordtransfer:in_progress_submission", kwargs={"uuid": self.other_in_progress.uuid}
+        )
+
+        response = self.client.delete(other_url, headers=self.headers)
+        self.assertEqual(response.status_code, 500)
path: app/recordtransfer/tests/unit/views/test_profile.py
user: login: danloveg
body: I think this should be a 404 instead of a 500
created_at: 2025-05-21T18:33:16Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,24 @@
+{% extends "includes/base_modal.html" %}
+{% load i18n %}
+{% block modal_id %}
+    delete_in_progress_submission_modal
+{% endblock modal_id %}
+{% block modal_title %}
+    {% trans "Are you sure you want to delete this in-progress submission?" %}
+{% endblock modal_title %}
+{% block modal_content %}
+    <p>
+        {% trans "Do you wish to delete the in-progress submission from" %} {{ in_progress.last_updated|date:"F j, Y g:ia" }}
+        {% if in_progress.title %}titled "{{ in_progress.title }}"{% endif %}
+        ?
path: app/recordtransfer/templates/includes/delete_in_progress_submission_modal.html
user: login: danloveg
body: This is split up in a way that makes translation difficult. How about changing it to this?

```suggestion
        {% if in_progress.title %}
            {% blocktrans with title=in_progress.title date=in_progress.last_updated|date:"F j, Y g:ia" %}
            Do you wish to delete the in-progress submission from {{ date }} titled "{{ title }}"?
            {% endblocktrans %}
        {% else %}
            {% blocktrans with date=in_progress.last_updated|date:"F j, Y g:ia" %}
            Do you wish to delete the in-progress submission from {{ date }}?
            {% endblocktrans %}
        {% endif %}
```
created_at: 2025-05-21T18:42:17Z
author_association: COLLABORATOR
diff_hunk: @@ -106,6 +114,34 @@ def form_invalid(self, form: BaseModelForm) -> HttpResponse:
         return super().form_invalid(profile_form)
 
 
+@require_http_methods(["GET", "DELETE"])
+def in_progress_submission(request: HttpRequest, uuid: str) -> HttpResponse:
+    """Handle GET (show modal) and DELETE (delete submission) for in-progress submissions. Both
+    requests must be made by HTMX, or else a 400 Error is returned.
+    """
+    if not request.htmx:
+        return HttpResponse(status=400)
+
+    if request.method == "GET":
+        in_progress = get_object_or_404(InProgressSubmission, uuid=uuid, user=request.user)
+        context = {"in_progress": in_progress}
+        return render(request, "includes/delete_in_progress_submission_modal.html", context)
+
+    # DELETE request
+    try:
+        in_progress = InProgressSubmission.objects.get(uuid=uuid, user=request.user)
+        in_progress.delete()
+        response = HttpResponse(status=204)
+        return trigger_client_event(
+            response, "showSuccess", {"value": "In-progress submission deleted."}
+        )
+    except Exception:
+        response = HttpResponse(status=500)
+        return trigger_client_event(
+            response, "showError", {"value": "Failed to delete in-progress submission."}
+        )
path: app/recordtransfer/views/profile.py
user: login: danloveg
body: I think get_object_or_404 should be used here, too. You'll have to change the Exception to let those exceptions through because that function raises an `Http404` exception. If you leave it as `except Exception` you'll mask the `Http404` exception as a 500 error
created_at: 2025-05-21T18:45:54Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,24 @@
+{% extends "includes/base_modal.html" %}
+{% load i18n %}
+{% block modal_id %}
+    delete_in_progress_submission_modal
+{% endblock modal_id %}
+{% block modal_title %}
+    {% trans "Are you sure you want to delete this in-progress submission?" %}
+{% endblock modal_title %}
+{% block modal_content %}
+    <p>
+        {% trans "Do you wish to delete the in-progress submission from" %} {{ in_progress.last_updated|date:"F j, Y g:ia" }}
+        {% if in_progress.title %}titled "{{ in_progress.title }}"{% endif %}
+        ?
path: app/recordtransfer/templates/includes/delete_in_progress_submission_modal.html
user: login: yenaing-oo
body: This makes sense to me, I also added `|escape` filters on the title and date to prevent HTML injection:
https://github.com/NationalCentreTruthReconciliation/Secure-Record-Transfer/pull/582/commits/f1bf04cffc850efa50ea4d5591535a4dcd29ca55
created_at: 2025-05-21T19:16:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -106,6 +114,34 @@ def form_invalid(self, form: BaseModelForm) -> HttpResponse:
         return super().form_invalid(profile_form)
 
 
+@require_http_methods(["GET", "DELETE"])
+def in_progress_submission(request: HttpRequest, uuid: str) -> HttpResponse:
+    """Handle GET (show modal) and DELETE (delete submission) for in-progress submissions. Both
+    requests must be made by HTMX, or else a 400 Error is returned.
+    """
+    if not request.htmx:
+        return HttpResponse(status=400)
+
+    if request.method == "GET":
+        in_progress = get_object_or_404(InProgressSubmission, uuid=uuid, user=request.user)
+        context = {"in_progress": in_progress}
+        return render(request, "includes/delete_in_progress_submission_modal.html", context)
+
+    # DELETE request
+    try:
+        in_progress = InProgressSubmission.objects.get(uuid=uuid, user=request.user)
+        in_progress.delete()
+        response = HttpResponse(status=204)
+        return trigger_client_event(
+            response, "showSuccess", {"value": "In-progress submission deleted."}
+        )
+    except Exception:
+        response = HttpResponse(status=500)
+        return trigger_client_event(
+            response, "showError", {"value": "Failed to delete in-progress submission."}
+        )
path: app/recordtransfer/views/profile.py
user: login: yenaing-oo
body: This makes sense, I've made the necessary changes here:
https://github.com/NationalCentreTruthReconciliation/Secure-Record-Transfer/pull/582/commits/dec95f6e009394c32dde36a1db541585834e1205
created_at: 2025-05-21T19:17:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -508,3 +509,160 @@ def test_submission_group_table_pagination(self) -> None:
         self.assertIn("Test Group 2", response.content.decode())
         self.assertNotIn("Test Group 0", response.content.decode())
         self.assertNotIn("Test Group 1", response.content.decode())
+
+
+class TestInProgressSubmission(TestCase):
+    """Tests for the in_progress_submission view."""
+
+    def setUp(self) -> None:
+        """Set up the test case with users and in-progress submissions."""
+        self.user = User.objects.create_user(
+            username="testuser1", email="test@example.com", password="testpassword"
+        )
+        self.other_user = User.objects.create_user(
+            username="testuser2", email="test2@example.com", password="testpassword"
+        )
+
+        # Create upload sessions for each user
+        self.upload_session = UploadSession.new_session(user=cast(User, self.user))
+        self.other_upload_session = UploadSession.new_session(user=cast(User, self.other_user))
+
+        # Create in-progress submissions for each user
+        self.in_progress = InProgressSubmission.objects.create(
+            user=self.user,
+            upload_session=self.upload_session,
+            current_step=SubmissionStep.CONTACT_INFO.value,
+            title="Test Submission 1",
+        )
+        self.other_in_progress = InProgressSubmission.objects.create(
+            user=self.other_user,
+            upload_session=self.other_upload_session,
+            current_step=SubmissionStep.CONTACT_INFO.value,
+            title="Test Submission 2",
+        )
+
+        self.client.force_login(self.user)
+        self.url = reverse(
+            "recordtransfer:in_progress_submission", kwargs={"uuid": self.in_progress.uuid}
+        )
+        self.headers = {
+            "HX-Request": "true",
+        }
+
+    # DELETE tests
+    def test_delete_success(self) -> None:
+        """Test successful deletion of an in-progress submission."""
+        self.assertTrue(InProgressSubmission.objects.filter(uuid=self.in_progress.uuid).exists())
+
+        response = self.client.delete(self.url, headers=self.headers)
+
+        # Check for proper status code
+        self.assertEqual(response.status_code, 204)
+
+        # Check that HTMX showSuccess event is included in the response
+        self.assertIn("HX-Trigger", response.headers)
+        self.assertIn("showSuccess", response.headers["HX-Trigger"])
+
+        # Check that deletion was successful
+        self.assertFalse(InProgressSubmission.objects.filter(uuid=self.in_progress.uuid).exists())
+
+    @patch("recordtransfer.views.profile.InProgressSubmission.delete")
+    def test_delete_error(self, mock_delete: MagicMock) -> None:
+        """Test that an error during deletion returns a 500 status code."""
+        mock_delete.side_effect = Exception("Deletion error")
+
+        response = self.client.delete(self.url, headers=self.headers)
+
+        # Check for proper status code
+        self.assertEqual(response.status_code, 500)
+
+        # Check that HTMX showError event is included in the response
+        self.assertIn("HX-Trigger", response.headers)
+        self.assertIn("showError", response.headers["HX-Trigger"])
+
+        # Check that the in-progress submission still exists
+        self.assertTrue(InProgressSubmission.objects.filter(uuid=self.in_progress.uuid).exists())
+
+    def test_non_htmx_delete_fails(self) -> None:
+        """Test that a non-HTMX request fails with a 400 status code."""
+        response = self.client.delete(self.url)
+
+        # Check for proper status code
+        self.assertEqual(response.status_code, 400)
+
+        # Check that the in-progress submission still exists
+        self.assertTrue(InProgressSubmission.objects.filter(uuid=self.in_progress.uuid).exists())
+
+    def test_cannot_delete_other_users_submission(self) -> None:
+        """Test that a user cannot delete another user's in-progress submission."""
+        other_url = reverse(
+            "recordtransfer:in_progress_submission", kwargs={"uuid": self.other_in_progress.uuid}
+        )
+
+        response = self.client.delete(other_url, headers=self.headers)
+        self.assertEqual(response.status_code, 500)
path: app/recordtransfer/tests/unit/views/test_profile.py
user: login: yenaing-oo
body: This makes sense, I've made the necessary changes here:
https://github.com/NationalCentreTruthReconciliation/Secure-Record-Transfer/pull/582/commits/dec95f6e009394c32dde36a1db541585834e1205
created_at: 2025-05-21T19:17:27Z
author_association: CONTRIBUTOR

PR comments:
user: login: yenaing-oo
created_at: 2025-05-20T22:19:58Z
body: Here's a summary of how it works:
- Clicking on an in-progress (IP) submission's delete button triggers HTMX to send a GET request to `delete_in_progress_modal`. HTMX fills the initially empty modal container with the response contents, then shows the modal when the "afterSwap" event is fired on `#modal-container`
- The "Yes" button to confirm delete has the `hx-delete` attribute, which sends a DELETE request for that IP submission upon click
- On the server side, `profile.delete_in_progress_submission()` handles the request and triggers a client event (either `showSuccess` or `showError`) using [`trigger_client_event`](https://django-htmx.readthedocs.io/en/latest/http.html#django_htmx.http.trigger_client_event) to include a message as well
- An event listener is set up to listen for the [htmx:afterRequest](https://htmx.org/events/#htmx:afterRequest) event, which fires regardless of whether the delete request was successful or not. The handler refreshes the table using [`htmx.ajax`](https://htmx.org/api/#ajax), only if the delete was successful.
- Other event listeners exist that are set up upon page load, which listen for events to show a Toast (see `toast.js`)

Note:
I spent quite a while trying to avoid loading the modal from the backend and simply using JS to populate an placeholder modal that already exists on the DOM, but this caused complications with HTMX, as setting HTMX attributes dynamically on an HTML element using JS does not work.
author_association: CONTRIBUTOR
user: login: yenaing-oo
created_at: 2025-05-21T17:47:55Z
body: @danloveg 
This PR is now ready for a review.
author_association: CONTRIBUTOR
user: login: yenaing-oo
created_at: 2025-05-21T19:21:25Z
body: I noticed I had accidentally removed the handler that DELETEs a submission group, whilst I was removing the confirmation modal on the Submission Group Detail page. I've added that functionality back now, but I've removed the old modal, since it was conflicting with the new modal styling. A restyled modal will be added back in https://github.com/NationalCentreTruthReconciliation/Secure-Record-Transfer/issues/574
Addressed by: https://github.com/NationalCentreTruthReconciliation/Secure-Record-Transfer/pull/582/commits/94f971a6c8ef4d529c70dd29774feb1c4e9f3316
author_association: CONTRIBUTOR