added form browser persistence
This commit is contained in:
Generated
+431
-134
File diff suppressed because it is too large
Load Diff
@@ -17,3 +17,5 @@ toml = "0.9.8"
|
|||||||
chrono = { version = "0.4.42", features = ["serde"] }
|
chrono = { version = "0.4.42", features = ["serde"] }
|
||||||
askama = "0.14.0"
|
askama = "0.14.0"
|
||||||
clap = { version = "4.5.53", features = ["derive"] }
|
clap = { version = "4.5.53", features = ["derive"] }
|
||||||
|
uuid = { version = "1.21.0", features = ["v4"] }
|
||||||
|
axum-extra = { version = "0.12.5", features = ["cookie"] }
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ form_title = "User Onboarding Survey"
|
|||||||
# Text displayed on the primary action button
|
# Text displayed on the primary action button
|
||||||
submit_button = "Complete Submission"
|
submit_button = "Complete Submission"
|
||||||
|
|
||||||
|
# Text displayed in the success popup shown after submitting
|
||||||
|
success_message = "Success sending submission"
|
||||||
|
|
||||||
|
# Text displayed in a popup shown after submitting, in case of an error
|
||||||
|
error_message = "Error while sending submission"
|
||||||
|
|
||||||
# Standard single-line input
|
# Standard single-line input
|
||||||
[[fields]]
|
[[fields]]
|
||||||
name = "username"
|
name = "username"
|
||||||
|
|||||||
+17
-11
@@ -49,6 +49,8 @@ pub struct AppConfig {
|
|||||||
pub json_output: Option<String>,
|
pub json_output: Option<String>,
|
||||||
pub form_title: String,
|
pub form_title: String,
|
||||||
pub submit_button: String,
|
pub submit_button: String,
|
||||||
|
pub success_message: String,
|
||||||
|
pub error_message: String,
|
||||||
pub fields: Vec<FieldDef>,
|
pub fields: Vec<FieldDef>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +67,8 @@ pub async fn render_form(State(state): State<AppState>) -> impl IntoResponse {
|
|||||||
struct FormTemplate<'a> {
|
struct FormTemplate<'a> {
|
||||||
form_title: &'a str,
|
form_title: &'a str,
|
||||||
submit_button: &'a str,
|
submit_button: &'a str,
|
||||||
|
success_message: &'a str,
|
||||||
|
error_message: &'a str,
|
||||||
fields: &'a [FieldDef],
|
fields: &'a [FieldDef],
|
||||||
lang: &'a str,
|
lang: &'a str,
|
||||||
}
|
}
|
||||||
@@ -72,6 +76,8 @@ pub async fn render_form(State(state): State<AppState>) -> impl IntoResponse {
|
|||||||
let tmpl = FormTemplate {
|
let tmpl = FormTemplate {
|
||||||
form_title: &state.cfg.form_title,
|
form_title: &state.cfg.form_title,
|
||||||
submit_button: &state.cfg.submit_button,
|
submit_button: &state.cfg.submit_button,
|
||||||
|
success_message: &state.cfg.success_message,
|
||||||
|
error_message: &state.cfg.error_message,
|
||||||
fields: &state.cfg.fields,
|
fields: &state.cfg.fields,
|
||||||
lang: "en",
|
lang: "en",
|
||||||
};
|
};
|
||||||
@@ -133,16 +139,16 @@ pub async fn submit(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// tracing::info!("Entry submitted: {:?}", entry.answers);
|
tracing::debug!("Entry submitted: {:?}", entry.answers);
|
||||||
|
|
||||||
Html(
|
// Html(
|
||||||
r#"
|
// r#"
|
||||||
<html>
|
// <html>
|
||||||
<body>
|
// <body>
|
||||||
<p>Saved. <a href="/">Back</a></p>
|
// <p>Saved. <a href="/">Back</a></p>
|
||||||
</body>
|
// </body>
|
||||||
</html>
|
// </html>
|
||||||
"#,
|
// "#,
|
||||||
)
|
// )
|
||||||
.into_response()
|
StatusCode::OK.into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ h1 {
|
|||||||
letter-spacing: -0.025em;
|
letter-spacing: -0.025em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1 i, h2 i, h3 i {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.form-container {
|
.form-container {
|
||||||
background-color: var(--bg-card);
|
background-color: var(--bg-card);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -215,6 +219,66 @@ button:hover {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transform: translateY(20px);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.active .modal-content {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-btn {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: var(--text-inv);
|
||||||
|
border: none;
|
||||||
|
padding: 0.8rem 2rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2.error-title {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
body {
|
body {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
|
/>
|
||||||
<title>{% block title %}Form{% endblock %}</title>
|
<title>{% block title %}Form{% endblock %}</title>
|
||||||
<style>
|
<style>
|
||||||
/*<![CDATA[*/
|
/*<![CDATA[*/
|
||||||
|
|||||||
+91
-13
@@ -6,16 +6,11 @@
|
|||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<h1>{{ form_title }}</h1>
|
<h1>{{ form_title }}</h1>
|
||||||
|
|
||||||
<form action="submit" method="POST" autocomplete="off">
|
<form id="form-gen" action="submit" method="POST" autocomplete="off">
|
||||||
{% for f in fields %}
|
{% for f in fields %}
|
||||||
|
{% if let Some(html) = f.html_before %}{{ html | safe }}{% endif %}
|
||||||
{% if let Some(html) = f.html_before %}
|
|
||||||
{{ html | safe }}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
{% match f.widget() %}
|
{% match f.widget() %}
|
||||||
|
|
||||||
{% when FieldWidget::Checkbox %}
|
{% when FieldWidget::Checkbox %}
|
||||||
<label for="{{ f.name }}" class="checkbox-label">
|
<label for="{{ f.name }}" class="checkbox-label">
|
||||||
<input id="{{ f.name }}" name="{{ f.name }}" type="checkbox" value="true">
|
<input id="{{ f.name }}" name="{{ f.name }}" type="checkbox" value="true">
|
||||||
@@ -50,14 +45,9 @@
|
|||||||
<span class="desc">{{ f.description }}</span>
|
<span class="desc">{{ f.description }}</span>
|
||||||
</label>
|
</label>
|
||||||
<input id="{{ f.name }}" name="{{ f.name }}" type="{{ input_type }}" placeholder="{{ f.title|lower }}">
|
<input id="{{ f.name }}" name="{{ f.name }}" type="{{ input_type }}" placeholder="{{ f.title|lower }}">
|
||||||
|
|
||||||
{% endmatch %}
|
{% endmatch %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if let Some(html) = f.html_after %}{{ html | safe }}{% endif %}
|
||||||
{% if let Some(html) = f.html_after %}
|
|
||||||
{{ html | safe }}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="submit-container">
|
<div class="submit-container">
|
||||||
@@ -65,4 +55,92 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="status-modal" class="modal-overlay">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2 id="modal-title">Status</h2>
|
||||||
|
<p id="modal-text"></p>
|
||||||
|
<button class="modal-close-btn" onclick="closeModal()">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById('form-gen');
|
||||||
|
const modal = document.getElementById('status-modal');
|
||||||
|
const modalTitle = document.getElementById('modal-title');
|
||||||
|
const modalText = document.getElementById('modal-text');
|
||||||
|
const STORAGE_KEY = 'form_generator_draft';
|
||||||
|
|
||||||
|
// Load Draft
|
||||||
|
function loadDraft() {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
Object.keys(data).forEach(key => {
|
||||||
|
const input = form.elements[key];
|
||||||
|
if (!input) return;
|
||||||
|
if (input.type === 'checkbox') input.checked = data[key] === "true";
|
||||||
|
else input.value = data[key];
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save Draft
|
||||||
|
function saveDraft() {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const data = {};
|
||||||
|
formData.forEach((value, key) => data[key] = value);
|
||||||
|
form.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||||
|
data[cb.name] = cb.checked ? "true" : "false";
|
||||||
|
});
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showModal(title, message, isError = false) {
|
||||||
|
modalTitle.innerText = title;
|
||||||
|
modalText.innerText = message;
|
||||||
|
|
||||||
|
modalTitle.className = isError ? 'error-title' : 'success-title';
|
||||||
|
|
||||||
|
modal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const submitBtn = form.querySelector('button[type="submit"]');
|
||||||
|
const originalBtnText = submitBtn.innerText;
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerText = "Submitting...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: new URLSearchParams(new FormData(form)),
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
form.reset();
|
||||||
|
showModal("{{ success_message }}", "", false);
|
||||||
|
} else {
|
||||||
|
showModal("{{ error_message }}", "", true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showModal("{{ error_message }}", err, true);
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerText = originalBtnText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('input', saveDraft);
|
||||||
|
window.addEventListener('DOMContentLoaded', loadDraft);
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user