Release 0.1.5 — AJAX auto-save on the reporting page (hc-66c)

The step cards saved via a full-page POST, and the reload broke the
technician's flow. Save in place instead:

- Status saves on change; notes save on blur (only when actually edited).
- Inline per-card feedback (Saving… / Saved ✓ / error), no reload.
- On a status change the header badge updates live and the escalation
  banner shows/hides itself, matching a full server render.

Progressive enhancement: a new wp_ajax_att_hc_save_step_ajax handler reuses
the same capability + nonce + step validation + write-through as the form
handler, which is kept as the no-JS fallback. Mirrors the existing
step-history AJAX loader.

Bumps the plugin header, ATT_HC_VERSION and updates.json so PUC offers this
to installed sites. Server-side unaffected — this is plugin-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 08:27:34 +01:00
parent 84dd00f3ec
commit a3d2a7807f
4 changed files with 161 additions and 10 deletions

View File

@@ -5,6 +5,7 @@ add_action('admin_menu', 'att_hc_register_menu');
add_action('admin_post_att_hc_start', 'att_hc_handle_start');
add_action('admin_post_att_hc_resume', 'att_hc_handle_resume');
add_action('wp_ajax_att_hc_step_history', 'att_hc_handle_step_history_ajax');
add_action('wp_ajax_att_hc_save_step_ajax', 'att_hc_handle_save_step_ajax');
add_action('admin_post_att_hc_save_step', 'att_hc_handle_save_step');
add_action('admin_post_att_hc_finish', 'att_hc_handle_finish');
add_action('admin_post_att_hc_save_next_due', 'att_hc_handle_save_next_due');
@@ -296,6 +297,7 @@ function att_hc_render_active_session(ATT_HC_Session $session): void {
}
att_hc_print_step_history_assets($session);
att_hc_print_step_save_assets($session);
echo '<div class="att-hc-layout">';
att_hc_render_sidebar($session);
@@ -417,6 +419,111 @@ function att_hc_print_step_history_assets(ATT_HC_Session $session): void {
<?php
}
/**
* Client-side auto-save for the step cards: status saves on change, notes save
* on blur, in place via AJAX. Progressive enhancement — the server-rendered
* form still works with JS off, and the explicit "Save step" button is only
* hidden once this wires up.
*/
function att_hc_print_step_save_assets(ATT_HC_Session $session): void {
$cfg = [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('att_hc_save_step_ajax'),
];
?>
<style>
.att-hc-step-save.js-live button[type="submit"],
.att-hc-step-save.js-live button:not([type]) { display: none; }
.att-hc-save-status.is-saving { color: #646970; }
.att-hc-save-status.is-saved { color: #1a8917; font-style: normal; }
.att-hc-save-status.is-error { color: #b32d2e; font-style: normal; }
.att-hc-step-save select.is-saving,
.att-hc-step-save textarea.is-saving { opacity: .6; }
</style>
<script>
(function () {
var cfg = <?php echo wp_json_encode($cfg); ?>;
function statusLabel(s) { return s.replace(/_/g, ' '); }
document.querySelectorAll('form.att-hc-step-save').forEach(function (form) {
var stepId = form.dataset.step;
var select = form.querySelector('select[name="status"]');
var notes = form.querySelector('textarea[name="notes"]');
var feedback = form.querySelector('.att-hc-save-status');
var card = form.closest('.att-hc-step');
if (!stepId || !select || !notes || !feedback) return;
// Signals JS is active: CSS hides the now-redundant Save button.
form.classList.add('js-live');
// Last value we've successfully persisted, so a blur with no edit is a no-op.
var savedNotes = notes.value;
function setFeedback(cls, text) {
feedback.className = 'att-hc-save-status description ' + cls;
feedback.textContent = text;
}
function save(field) {
var body = new FormData();
body.append('action', 'att_hc_save_step_ajax');
body.append('nonce', cfg.nonce);
body.append('step', stepId);
body.append('status', select.value);
body.append('notes', notes.value);
field.classList.add('is-saving');
setFeedback('is-saving', 'Saving…');
return fetch(cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: body })
.then(function (r) { return r.json(); })
.then(function (j) {
field.classList.remove('is-saving');
if (!j || !j.success) {
setFeedback('is-error', (j && j.data) || 'Save failed.');
return;
}
savedNotes = notes.value;
applyStatus(j.data.status, j.data.status_label);
setFeedback('is-saved', 'Saved ✓');
})
.catch(function (e) {
field.classList.remove('is-saving');
setFeedback('is-error', 'Network error — not saved: ' + e.message);
});
}
// Reflect a status change in the header badge + escalation banner,
// matching what a full server render would have produced.
function applyStatus(status, label) {
if (card) {
var badge = card.querySelector('header .att-hc-step-status');
if (badge) {
badge.className = 'att-hc-step-status att-hc-status-' + status;
badge.textContent = label || statusLabel(status);
}
var esc = card.querySelector('[data-role="escalation"]');
if (esc) esc.hidden = (status !== 'blocked');
}
}
select.addEventListener('change', function () { save(select); });
notes.addEventListener('blur', function () {
if (notes.value === savedNotes) return; // nothing changed
save(notes);
});
// Enter / clicking the fallback button (if somehow visible): save in place.
form.addEventListener('submit', function (e) {
e.preventDefault();
save(notes);
});
});
})();
</script>
<?php
}
function att_hc_render_sidebar(ATT_HC_Session $session): void {
echo '<aside class="att-hc-sidebar"><div class="att-hc-card"><h3>Steps</h3><ol>';
foreach (ATT_HC_Steps::instance()->all() as $step) {
@@ -533,8 +640,9 @@ function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step, int
</ul>
</div>
<?php endif; ?>
<?php if ($status === ATT_HC_Session::STATUS_BLOCKED && ($esc = $step->escalation())): ?>
<div class="att-hc-escalation"><?php echo esc_html($esc); ?></div>
<?php if ($esc = $step->escalation()): ?>
<?php // Rendered always (hidden unless blocked) so an AJAX status change can toggle it without a reload. ?>
<div class="att-hc-escalation" data-role="escalation"<?php echo $status === ATT_HC_Session::STATUS_BLOCKED ? '' : ' hidden'; ?>><?php echo esc_html($esc); ?></div>
<?php endif; ?>
<?php
@@ -545,7 +653,7 @@ function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step, int
$step->render_extra($session->data());
?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="att-hc-step-save">
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="att-hc-step-save" data-step="<?php echo esc_attr($step->id()); ?>">
<?php wp_nonce_field('att_hc_save_step_' . $step->id()); ?>
<input type="hidden" name="action" value="att_hc_save_step">
<input type="hidden" name="step" value="<?php echo esc_attr($step->id()); ?>">
@@ -564,10 +672,13 @@ function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step, int
<textarea name="notes" placeholder="What did you check, find, fix, or flag?"><?php echo esc_textarea($notes); ?></textarea>
</p>
<p>
<?php // JS hides this button (saves happen on change/blur); it stays as the no-JS fallback. ?>
<button class="button button-primary">Save step</button>
<?php if ($state['updated_at']): ?>
<span class="description">Last saved <?php echo esc_html(human_time_diff($state['updated_at'], time())); ?> ago</span>
<?php endif; ?>
<span class="att-hc-save-status description" aria-live="polite"><?php
if ($state['updated_at']) {
echo 'Last saved ' . esc_html(human_time_diff($state['updated_at'], time())) . ' ago';
}
?></span>
</p>
</form>
</div>
@@ -725,6 +836,45 @@ function att_hc_handle_save_step(): void {
exit;
}
/**
* AJAX twin of att_hc_handle_save_step(). Same validation and write-through,
* but returns JSON instead of redirecting, so the reporting page can save a
* step in place (on status change / notes blur) without a full reload.
*
* The non-AJAX form POST above is kept as the no-JS fallback.
*/
function att_hc_handle_save_step_ajax(): void {
if (!current_user_can('manage_options')) wp_send_json_error('Forbidden', 403);
if (!check_ajax_referer('att_hc_save_step_ajax', 'nonce', false)) {
wp_send_json_error('Bad nonce — reload the page and try again.', 403);
}
$step_id = isset($_POST['step']) ? sanitize_key((string) $_POST['step']) : '';
if ($step_id === '') wp_send_json_error('Missing step id', 400);
$session = ATT_HC_Session::current();
if (!$session || $session->is_finished()) wp_send_json_error('No active session', 409);
if (!ATT_HC_Steps::instance()->get($step_id)) wp_send_json_error('Unknown step', 400);
$status = isset($_POST['status']) ? sanitize_key((string) $_POST['status']) : ATT_HC_Session::STATUS_NOT_STARTED;
$notes = isset($_POST['notes']) ? wp_unslash((string) $_POST['notes']) : '';
try {
// update_step() re-validates the status and falls back to NOT_STARTED
// for anything unexpected, so we report back whatever was actually stored.
$session->update_step($step_id, $status, $notes);
} catch (ATT_HC_Api_Exception $e) {
wp_send_json_error('Not saved — the central server rejected the write: ' . $e->getMessage(), 502);
}
$state = $session->step_state($step_id);
wp_send_json_success([
'step' => $step_id,
'status' => (string) $state['status'],
'status_label' => str_replace('_', ' ', (string) $state['status']),
'updated_at' => (int) $state['updated_at'],
]);
}
function att_hc_handle_finish(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('att_hc_finish');