feat(kanban): add local recovery controls
req: local/003\nreq: security/007
This commit is contained in:
@@ -2,6 +2,8 @@ const DATABASE = "hemx-kanban-v1";
|
||||
const COMMANDS = "commands";
|
||||
const META = "meta";
|
||||
const COMMAND_SCHEMA = 1;
|
||||
const EXPORT_SCHEMA = 1;
|
||||
const SESSION = "hemx-kanban-session-v1";
|
||||
const ROOT = '[data-hemx-root][data-hemx-client-module="/kanban_client.js"]';
|
||||
|
||||
function result(request) {
|
||||
@@ -52,11 +54,10 @@ async function prepareOfflineShell(root) {
|
||||
}
|
||||
|
||||
function stableSession() {
|
||||
const key = "hemx-kanban-session-v1";
|
||||
let session = sessionStorage.getItem(key);
|
||||
let session = sessionStorage.getItem(SESSION);
|
||||
if (!session) {
|
||||
session = crypto.randomUUID();
|
||||
sessionStorage.setItem(key, session);
|
||||
sessionStorage.setItem(SESSION, session);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
@@ -126,6 +127,89 @@ function report(root, stage, error) {
|
||||
root.dispatchEvent(new CustomEvent("kanban:command-error", { detail: { stage, message } }));
|
||||
}
|
||||
|
||||
function announce(root, message) {
|
||||
const status = root.querySelector('[role="status"]');
|
||||
if (status) status.textContent = message;
|
||||
}
|
||||
|
||||
function exportCommands(root, commands) {
|
||||
const payload = { schemaVersion: EXPORT_SCHEMA, commands };
|
||||
const json = JSON.stringify(payload, null, 2);
|
||||
const url = URL.createObjectURL(new Blob([json], { type: "application/json" }));
|
||||
const download = document.createElement("a");
|
||||
download.href = url;
|
||||
download.download = "hemx-kanban-commands.json";
|
||||
download.hidden = true;
|
||||
document.body.append(download);
|
||||
download.click();
|
||||
download.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
announce(root, `Exported ${commands.length} command${commands.length === 1 ? "" : "s"}.`);
|
||||
root.dispatchEvent(new CustomEvent("kanban:commands-exported", { detail: payload }));
|
||||
}
|
||||
|
||||
async function clearCommands(database) {
|
||||
const transaction = database.transaction(COMMANDS, "readwrite");
|
||||
const done = completed(transaction);
|
||||
transaction.objectStore(COMMANDS).clear();
|
||||
await done;
|
||||
}
|
||||
|
||||
async function resetLocalData(database) {
|
||||
database.close();
|
||||
await result(indexedDB.deleteDatabase(DATABASE));
|
||||
sessionStorage.removeItem(SESSION);
|
||||
await Promise.all((await caches.keys()).filter((name) => name.startsWith("hemx-kanban-shell-")).map((name) => caches.delete(name)));
|
||||
await Promise.all((await navigator.serviceWorker.getRegistrations()).map((registration) => registration.unregister()));
|
||||
}
|
||||
|
||||
function disarmRecoveryControls(controls) {
|
||||
for (const control of controls) {
|
||||
if (!control.dataset.confirmLabel) continue;
|
||||
control.textContent = control.dataset.confirmLabel;
|
||||
delete control.dataset.confirmLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function installRecoveryControls(root, database) {
|
||||
const controls = [...root.querySelectorAll("[data-kanban-command-action]")];
|
||||
for (const control of controls) {
|
||||
control.addEventListener("click", async () => {
|
||||
const action = control.getAttribute("data-kanban-command-action");
|
||||
if ((action === "delete" || action === "reset") && !control.dataset.confirmLabel) {
|
||||
disarmRecoveryControls(controls);
|
||||
control.dataset.confirmLabel = control.textContent;
|
||||
control.textContent = `Confirm ${control.textContent.toLowerCase()}`;
|
||||
announce(root, `${control.dataset.confirmLabel} requires confirmation.`);
|
||||
return;
|
||||
}
|
||||
if (action === "export") disarmRecoveryControls(controls);
|
||||
controls.forEach((item) => { item.disabled = true; });
|
||||
try {
|
||||
if (action === "export") {
|
||||
exportCommands(root, await storedCommands(database));
|
||||
controls.forEach((item) => { item.disabled = false; });
|
||||
return;
|
||||
}
|
||||
if (action === "delete") {
|
||||
await clearCommands(database);
|
||||
root.dispatchEvent(new CustomEvent("kanban:commands-deleted"));
|
||||
} else if (action === "reset") {
|
||||
await resetLocalData(database);
|
||||
root.dispatchEvent(new CustomEvent("kanban:local-data-reset"));
|
||||
} else {
|
||||
throw new Error(`unsupported recovery action ${action}`);
|
||||
}
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
controls.forEach((item) => { item.disabled = false; });
|
||||
disarmRecoveryControls(controls);
|
||||
report(root, action || "recovery", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const root = document.querySelector(ROOT);
|
||||
if (!root) return;
|
||||
@@ -163,6 +247,7 @@ async function start() {
|
||||
wasmHandler = window.hemx.registerClientHandler("reorder_card", durableHandler);
|
||||
if (typeof wasmHandler !== "function") throw new Error("reorder_card WASM handler is not registered");
|
||||
const database = await databasePromise;
|
||||
installRecoveryControls(root, database);
|
||||
try {
|
||||
const commands = await storedCommands(database);
|
||||
for (const command of commands) window.hemx.applyBatch(await project(root, wasmHandler, command), root);
|
||||
|
||||
@@ -5,5 +5,11 @@
|
||||
{+ card +}
|
||||
</template>
|
||||
</ul>
|
||||
<fieldset>
|
||||
<legend>Offline commands</legend>
|
||||
<button type="button" data-kanban-command-action="export">Export commands</button>
|
||||
<button type="button" data-kanban-command-action="delete">Delete commands</button>
|
||||
<button type="button" data-kanban-command-action="reset">Reset local data</button>
|
||||
</fieldset>
|
||||
<div data-hemx-handle="reorder_card" data-hemx-on="drop" data-hemx-client="reorder_card" data-hemx-client-event="drop" data-hemx-client-policy="latest">Drop card</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user