| 1 |
// Entries are shaped like the save endpoint's request body so replay is just |
| 2 |
// save(entry). Cap protects localStorage size. |
| 3 |
|
| 4 |
import { track } from '@shared/lib/track'; |
| 5 |
import { __, sprintf } from '@wordpress/i18n'; |
| 6 |
import { |
| 7 |
save, |
| 8 |
saveProduct, |
| 9 |
saveSiteIdentity, |
| 10 |
saveWpFormsField, |
| 11 |
saveWpNavigationItem, |
| 12 |
} from '../lib/api'; |
| 13 |
|
| 14 |
const STORAGE_KEY = 'extendify-quick-edit-undo-stack-v1'; |
| 15 |
const MAX_DEPTH = 5; |
| 16 |
const ANNOUNCE_KEY = 'extendify-quick-edit-undo-announce'; |
| 17 |
|
| 18 |
const loadStack = () => { |
| 19 |
try { |
| 20 |
const raw = window.localStorage.getItem(STORAGE_KEY); |
| 21 |
const arr = raw ? JSON.parse(raw) : []; |
| 22 |
return Array.isArray(arr) ? arr : []; |
| 23 |
} catch (_) { |
| 24 |
return []; |
| 25 |
} |
| 26 |
}; |
| 27 |
|
| 28 |
const saveStack = (stack) => { |
| 29 |
try { |
| 30 |
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(stack)); |
| 31 |
} catch (_) { |
| 32 |
// quota / private mode |
| 33 |
} |
| 34 |
}; |
| 35 |
|
| 36 |
export const pushUndo = (entry) => { |
| 37 |
if (!entry) return; |
| 38 |
const kind = entry.kind || (entry.rawBlock ? 'block' : 'patches'); |
| 39 |
const stack = loadStack(); |
| 40 |
stack.push({ ...entry, kind, ts: Date.now() }); |
| 41 |
while (stack.length > MAX_DEPTH) stack.shift(); |
| 42 |
saveStack(stack); |
| 43 |
}; |
| 44 |
|
| 45 |
const popUndo = () => { |
| 46 |
const stack = loadStack(); |
| 47 |
const entry = stack.pop(); |
| 48 |
saveStack(stack); |
| 49 |
return entry; |
| 50 |
}; |
| 51 |
|
| 52 |
export const getStackDepth = () => loadStack().length; |
| 53 |
|
| 54 |
// Returns false on empty stack so callers can show feedback. |
| 55 |
export const performUndo = () => { |
| 56 |
const entry = popUndo(); |
| 57 |
if (!entry) return false; |
| 58 |
// Site identity lives in wp_options, not post_content; route to its own endpoint. |
| 59 |
let promise; |
| 60 |
if (entry.identityReplay) { |
| 61 |
promise = saveSiteIdentity(entry.beforeValues || {}); |
| 62 |
} else if (entry.productReplay) { |
| 63 |
// Routes through WCProductController; price entries carry { regular, sale }. |
| 64 |
promise = saveProduct({ |
| 65 |
productId: entry.productId, |
| 66 |
field: entry.field, |
| 67 |
value: entry.beforeValue, |
| 68 |
}); |
| 69 |
} else if (entry.navReplay) { |
| 70 |
// Routes through wp-navigation; ref-based nav items live in their own CPT. |
| 71 |
promise = saveWpNavigationItem({ |
| 72 |
navPostId: entry.navPostId, |
| 73 |
itemIndex: entry.itemIndex, |
| 74 |
blockType: entry.blockType, |
| 75 |
patches: entry.patches, |
| 76 |
}); |
| 77 |
} else if (entry.wpformsReplay) { |
| 78 |
// WPForms fields live in the form's serialized JSON, not post_content; |
| 79 |
// replay through the shallow-merge changes-bag the forward save used. |
| 80 |
promise = saveWpFormsField({ |
| 81 |
formId: entry.formId, |
| 82 |
fieldId: entry.fieldId, |
| 83 |
changes: entry.changes, |
| 84 |
}); |
| 85 |
} else { |
| 86 |
const { kind: _kind, ts: _ts, ...payload } = entry; |
| 87 |
promise = save(payload); |
| 88 |
} |
| 89 |
promise |
| 90 |
.then(() => { |
| 91 |
track('undo', { kind: entry.kind }); |
| 92 |
// aria-live announcement survives the reload via sessionStorage. |
| 93 |
try { |
| 94 |
window.sessionStorage.setItem( |
| 95 |
ANNOUNCE_KEY, |
| 96 |
__('Reverted last change.', 'extendify-local'), |
| 97 |
); |
| 98 |
} catch (_) { |
| 99 |
// quota / private mode |
| 100 |
} |
| 101 |
window.location.reload(); |
| 102 |
}) |
| 103 |
.catch((err) => { |
| 104 |
track('undo_failed', { kind: entry.kind }); |
| 105 |
window.alert( |
| 106 |
sprintf( |
| 107 |
// translators: %s is the underlying error message. |
| 108 |
__('Undo failed: %s', 'extendify-local'), |
| 109 |
err?.message || __('unknown error', 'extendify-local'), |
| 110 |
), |
| 111 |
); |
| 112 |
}); |
| 113 |
return true; |
| 114 |
}; |
| 115 |
|
| 116 |
export const ANNOUNCE_STORAGE_KEY = ANNOUNCE_KEY; |
| 117 |
|