diff --git a/app/eventyay/cfp/flow.py b/app/eventyay/cfp/flow.py index 551cc31297..6bb3b2a7bb 100644 --- a/app/eventyay/cfp/flow.py +++ b/app/eventyay/cfp/flow.py @@ -13,6 +13,7 @@ from django.db.models import Q from django.forms import ValidationError from django.http import HttpResponseNotAllowed +from django.utils.datastructures import MultiValueDict from django.shortcuts import redirect from django.urls import reverse from django.utils.functional import Promise, cached_property @@ -219,14 +220,40 @@ def get_form_initial(self): return copy.deepcopy({**initial_data, **previous_data}) def get_form(self, from_storage=False): - if self.request.method == 'GET' or from_storage: + # Cache form initial data to avoid repeated work + form_initial = self.get_form_initial() + + if self.request.method == 'GET': + # For initial GET requests, use an unbound form populated from session data + # This shows saved values but intentionally does not display validation errors + return self.form_class( + data=None, + initial=form_initial, + files=None, + **self.get_form_kwargs(), + ) + if from_storage: + # For validation checks, create a bound form with session data return self.form_class( - data=self.get_form_initial() if from_storage else None, - initial=self.get_form_initial(), + data=form_initial, + initial=form_initial, files=self.get_files(), **self.get_form_kwargs(), ) - return self.form_class(data=self.request.POST, files=self.request.FILES, **self.get_form_kwargs()) + # For POST requests, merge new uploads with existing session files + # This allows users to navigate back without losing previously uploaded files + session_files = self.get_files() or {} + + # Preserve MultiValueDict semantics for proper multi-file field support + files = MultiValueDict() + # Add session files first + for field, file_obj in session_files.items(): + files[field] = file_obj + # For each field, new uploads completely replace any existing session files + for field, file_list in self.request.FILES.lists(): + files.setlist(field, file_list) + + return self.form_class(data=self.request.POST, files=files, **self.get_form_kwargs()) def is_completed(self, request): self.request = request @@ -237,11 +264,29 @@ def get_context_data(self, **kwargs): result['form'] = self.get_form() previous_data = self.cfp_session.get('data') result['submission_title'] = previous_data.get('info', {}).get('title') + # Add information about uploaded files for display in templates + saved_files = self.cfp_session.get('files', {}).get(self.identifier, {}) or {} + result['uploaded_files'] = { + field: file_dict.get('name') for field, file_dict in saved_files.items() + } return result def post(self, request): self.request = request form = self.get_form() + action = request.POST.get('action', 'submit') + + # For "back" action, only save data if form is valid + if action == 'back': + if form.is_valid(): + self.set_data(form.cleaned_data) + # Always save files if present + if form.files: + self.set_files(form.files) + prev_url = self.get_prev_url(request) + return redirect(prev_url) if prev_url else redirect(request.path) + + # For "submit" and "draft" actions, validate as before if not form.is_valid(): error_message = '\n\n'.join( (f'{form.fields[key].label}: ' if key != '__all__' else '') + ' '.join(values) diff --git a/app/eventyay/cfp/templates/cfp/event/submission_base.html b/app/eventyay/cfp/templates/cfp/event/submission_base.html index 5284913e1d..810fd20650 100644 --- a/app/eventyay/cfp/templates/cfp/event/submission_base.html +++ b/app/eventyay/cfp/templates/cfp/event/submission_base.html @@ -13,7 +13,8 @@ {% block cfp_header %} {% include "cfp/includes/forms_header.html" %} {% compress js %} - + + {% endcompress %} {% block cfp_submission_header %}{% endblock cfp_submission_header %} {% endblock cfp_header %} @@ -39,7 +40,7 @@ {% block cfp_form %} {% if request.user.is_authenticated %} -
+ {% csrf_token %} {{ wizard.management_form }}
@@ -49,6 +50,16 @@

{% block submission_step_title %}{{ title|default:'' }}{% endblock submissio

{% block submission_step_text %}

{{ text|rich_text }}

{% endblock %} {% block inner %} + {% if uploaded_files %} +
+ {% translate "Previously uploaded files:" %} + +
+ {% endif %} {{ form }} {% endblock inner %} {% block buttons %} @@ -76,9 +87,9 @@

{% block submission_step_title %}{{ title|default:'' }}{% endblock submissio
{% if prev_url %} - + {% endif %}
diff --git a/app/eventyay/cfp/views/wizard.py b/app/eventyay/cfp/views/wizard.py index 7e2107e203..b86830ff46 100644 --- a/app/eventyay/cfp/views/wizard.py +++ b/app/eventyay/cfp/views/wizard.py @@ -71,6 +71,10 @@ def dispatch(self, request, *args, **kwargs): or step.identifier == 'user' ], ) + if request.method == 'POST' and request.POST.get('action') == 'back': + # When clicking Back, the step's POST handler has already saved the data + # Now redirect to the previous step + return result if request.method == 'GET' or (step.get_next_applicable(request) or not step.is_completed(request)): if result and (csp_change := step.get_csp_update(request)): result._csp_update = csp_change diff --git a/app/eventyay/static/cfp/js/formAutoSave.js b/app/eventyay/static/cfp/js/formAutoSave.js new file mode 100644 index 0000000000..194d0a66e3 --- /dev/null +++ b/app/eventyay/static/cfp/js/formAutoSave.js @@ -0,0 +1,206 @@ +/** + * Auto-save CfP form data to sessionStorage to preserve it during browser navigation + * This ensures data is not lost when users use the browser back button + */ + +'use strict'; + +// Get unique storage key based on the current URL (includes tmpid and step) +function getStorageKey() { + const path = window.location.pathname; + return `cfp_form_data_${path}`; +} + +// Debounce function to avoid saving too frequently +function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +// Save form data to sessionStorage +function saveFormData() { + const form = document.getElementById('cfp-submission-form'); + if (!form) return; + + const formData = {}; + + // Find all form fields using querySelectorAll for reliability + const textareas = form.querySelectorAll('textarea'); + const inputs = form.querySelectorAll('input:not([type="hidden"]):not([type="submit"])'); + const selects = form.querySelectorAll('select'); + + // Process all textareas + textareas.forEach((textarea) => { + const name = textarea.name; + if (name && name !== 'csrfmiddlewaretoken') { + formData[name] = textarea.value; + } + }); + + // Process all inputs + inputs.forEach((input) => { + const name = input.name; + const type = input.type; + + if (!name || name === 'csrfmiddlewaretoken' || name === 'action') return; + + if (type === 'checkbox') { + formData[name] = input.checked; + } else if (type === 'radio') { + if (input.checked) { + formData[name] = input.value; + } + } else if (type !== 'file') { + formData[name] = input.value; + } + }); + + // Process all selects + selects.forEach((select) => { + const name = select.name; + if (name && name !== 'csrfmiddlewaretoken') { + if (select.multiple) { + formData[name] = Array.from(select.selectedOptions).map(opt => opt.value); + } else { + formData[name] = select.value; + } + } + }); + + try { + sessionStorage.setItem(getStorageKey(), JSON.stringify(formData)); + } catch (e) { + console.warn('Failed to save form data to sessionStorage:', e); + } +} + +// Restore form data from sessionStorage +function restoreFormData() { + try { + const savedData = sessionStorage.getItem(getStorageKey()); + if (!savedData) return; + + const formData = JSON.parse(savedData); + const form = document.getElementById('cfp-submission-form'); + if (!form) return; + + // Check if the saved sessionStorage data matches the current form data + // If they match exactly, it means we successfully submitted and the server + // echoed back our data - in this case, clear sessionStorage + let allFieldsMatch = true; + let checkedFields = 0; + + for (const [name, savedValue] of Object.entries(formData)) { + const elements = form.elements[name]; + if (!elements) continue; + + const element = elements.length !== undefined ? elements[0] : elements; + const currentValue = element.value || ''; + const savedValueStr = String(savedValue || ''); + + checkedFields++; + if (currentValue.trim() !== savedValueStr.trim()) { + allFieldsMatch = false; + break; + } + } + + // If all saved fields match current values AND we checked some fields, + // it means the form was successfully submitted - clear sessionStorage + if (allFieldsMatch && checkedFields > 0) { + clearFormData(); + return; + } + + // Otherwise, restore from sessionStorage + for (const [name, value] of Object.entries(formData)) { + const elements = form.elements[name]; + if (!elements) continue; + + // Handle NodeList (radio buttons, checkboxes with same name) + if (elements.length > 1) { + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + if (element.type === 'checkbox') { + element.checked = value; + } else if (element.type === 'radio') { + element.checked = (element.value === value); + } + } + } else { + const element = elements.length !== undefined ? elements[0] : elements; + const type = element.type; + + if (type === 'checkbox') { + element.checked = value; + } else if (type === 'radio') { + element.checked = (element.value === value); + } else if (element.tagName === 'SELECT') { + if (element.multiple && Array.isArray(value)) { + for (let i = 0; i < element.options.length; i++) { + element.options[i].selected = value.includes(element.options[i].value); + } + } else { + element.value = value; + } + } else if (element.tagName === 'TEXTAREA' || element.tagName === 'INPUT') { + // Always restore - sessionStorage takes precedence + // If we got this far, the matching logic already determined + // this is new data that should be restored + element.value = value; + } + } + } + } catch (e) { + console.warn('Failed to restore form data from sessionStorage:', e); + } +} + +// Clear saved form data (called after successful submission) +function clearFormData() { + try { + sessionStorage.removeItem(getStorageKey()); + } catch (e) { + console.warn('Failed to clear form data from sessionStorage:', e); + } +} + +// Initialize auto-save functionality +function init() { + const form = document.getElementById('cfp-submission-form'); + if (!form) return; + + // Restore form data on page load + restoreFormData(); + + // Create debounced save function (save 500ms after user stops typing) + const debouncedSave = debounce(saveFormData, 500); + + // Attach event listeners to form elements + form.addEventListener('input', debouncedSave); + form.addEventListener('change', debouncedSave); + + // Save before page unload (browser back button, refresh, close tab, etc.) + // This ensures data is preserved even when using browser navigation + window.addEventListener('beforeunload', saveFormData); + + // Note: We don't clear sessionStorage on form submit because: + // 1. We can't reliably detect if submission succeeded (might have validation errors) + // 2. The restore logic already handles this by clearing sessionStorage when + // it detects the form has server data (successful previous submission) +} + +// Initialize when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} + diff --git a/app/eventyay/static/cfp/js/proposalTabTitles.js b/app/eventyay/static/cfp/js/proposalTabTitles.js index daf953374b..2595cac5bb 100644 --- a/app/eventyay/static/cfp/js/proposalTabTitles.js +++ b/app/eventyay/static/cfp/js/proposalTabTitles.js @@ -9,8 +9,10 @@ const updateTitle = (newTitle) => { } const checkForTitle = () => { - const titleInput = document.getElementById("id_title").value - updateTitle(titleInput) + const titleInput = document.getElementById("id_title") + if (titleInput) { + updateTitle(titleInput.value) + } } if (titleParts.length !== 3) { @@ -19,5 +21,8 @@ if (titleParts.length !== 3) { ) } else { onReady(checkForTitle) - document.getElementById("id_title").addEventListener("change", (ev) => { updateTitle(ev.target.value) }) + const titleInput = document.getElementById("id_title") + if (titleInput) { + titleInput.addEventListener("change", (ev) => { updateTitle(ev.target.value) }) + } }