Add local data reset dialog and enhance footer navigation with long press functionality

This commit is contained in:
Jeppe Bundgaard
2026-06-11 21:05:36 +02:00
parent d97b02bace
commit 41e0320ce1
3 changed files with 302 additions and 13 deletions
@@ -0,0 +1,151 @@
<script setup>
defineProps({
modelValue: {
type: Boolean,
default: false,
},
busy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "confirm"]);
const closeDialog = () => {
emit("update:modelValue", false);
};
</script>
<template>
<Teleport to="body">
<div v-if="modelValue" class="local-data-reset-dialog" data-testid="local-data-reset-dialog">
<button
type="button"
class="local-data-reset-dialog__backdrop"
aria-label="Luk"
:disabled="busy"
@click="closeDialog"
></button>
<section
class="local-data-reset-dialog__panel"
role="dialog"
aria-modal="true"
aria-labelledby="local-data-reset-dialog-title"
>
<h2 id="local-data-reset-dialog-title">Ryd lokale data?</h2>
<p>
Dette sletter login, localStorage, sessionStorage, browsercache og lokale appdata denne enhed. Du bliver
logget ud.
</p>
<footer class="local-data-reset-dialog__actions">
<button
type="button"
class="local-data-reset-dialog__button local-data-reset-dialog__button--secondary"
data-testid="local-data-reset-cancel"
:disabled="busy"
@click="closeDialog"
>
Nej
</button>
<button
type="button"
class="local-data-reset-dialog__button local-data-reset-dialog__button--danger"
data-testid="local-data-reset-confirm"
:disabled="busy"
@click="emit('confirm')"
>
Ja, ryd alt
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.local-data-reset-dialog {
position: fixed;
inset: 0;
z-index: 11000;
display: grid;
place-items: center;
padding: 18px;
pointer-events: none;
}
.local-data-reset-dialog__backdrop {
position: fixed;
inset: 0;
border: 0;
background: rgba(9, 20, 33, 0.48);
cursor: pointer;
pointer-events: auto;
}
.local-data-reset-dialog__backdrop:disabled {
cursor: wait;
}
.local-data-reset-dialog__panel {
position: relative;
width: min(430px, calc(100vw - 36px));
border: 1px solid #d5dde8;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28);
color: #172033;
pointer-events: auto;
}
.local-data-reset-dialog__panel h2 {
margin: 0;
padding: 18px 18px 8px;
font-size: 1.05rem;
font-weight: 800;
letter-spacing: 0;
}
.local-data-reset-dialog__panel p {
margin: 0;
padding: 0 18px 16px;
color: #475467;
font-size: 0.92rem;
font-weight: 550;
line-height: 1.42;
}
.local-data-reset-dialog__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 18px 18px;
border-top: 1px solid #edf1f5;
}
.local-data-reset-dialog__button {
min-height: 38px;
padding: 0 14px;
border-radius: 6px;
cursor: pointer;
font-size: 0.86rem;
font-weight: 800;
}
.local-data-reset-dialog__button:disabled {
cursor: wait;
opacity: 0.68;
}
.local-data-reset-dialog__button--secondary {
border: 1px solid #cfd8e3;
background: #ffffff;
color: #334155;
}
.local-data-reset-dialog__button--danger {
border: 1px solid #b42318;
background: #b42318;
color: #ffffff;
}
</style>
@@ -1,10 +1,20 @@
<script setup lang="ts">
import {BButton, BField, BIcon} from "buefy";
import {computed, ref} from "vue";
import {computed, onBeforeUnmount, ref, watch} from "vue";
import { useRouter } from "vue-router";
import { IS_DEV } from '@/config.js';
import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
const router = useRouter();
const HOME_LONG_PRESS_MS = 5000;
const HOME_CLICK_SUPPRESS_MS = 1200;
type FooterPage = {
to: string;
label: string;
icon: string;
disabled: boolean;
};
const show = computed(() => {
const path = router.currentRoute.value.path;
@@ -12,10 +22,16 @@ const show = computed(() => {
return path.startsWith("/user");
});
const currentPath = computed(() => router.currentRoute.value.path);
const isLocalDataResetOpen = ref(false);
const isClearingLocalData = ref(false);
const suppressHomeClickUntil = ref(0);
let homeLongPressTimer: ReturnType<typeof window.setTimeout> | null = null;
/**
* Page navigation footer for mobile devices
*/
const pages = {
const pages: Record<string, FooterPage> = {
home: {
to: "/user",
label: "Hjem",
@@ -35,6 +51,78 @@ const pages = {
disabled: false
},
}
const cancelHomeLongPress = () => {
if (homeLongPressTimer !== null) {
window.clearTimeout(homeLongPressTimer);
homeLongPressTimer = null;
}
};
const startHomeLongPress = (event: PointerEvent) => {
if (event.pointerType === "mouse" && event.button !== 0) {
return;
}
if (isLocalDataResetOpen.value || isClearingLocalData.value) {
return;
}
cancelHomeLongPress();
homeLongPressTimer = window.setTimeout(() => {
homeLongPressTimer = null;
suppressHomeClickUntil.value = Date.now() + HOME_CLICK_SUPPRESS_MS;
isLocalDataResetOpen.value = true;
}, HOME_LONG_PRESS_MS);
};
const footerButtonListeners = (key: string) =>
key === "home"
? {
pointerdown: startHomeLongPress,
pointerup: cancelHomeLongPress,
pointercancel: cancelHomeLongPress,
pointerleave: cancelHomeLongPress,
}
: {};
const isActiveFooterRoute = (path: string) => currentPath.value.endsWith(path);
const handlePageClick = (event: MouseEvent, key: string, page: FooterPage) => {
if (page.disabled) {
return;
}
if (key === "home" && Date.now() <= suppressHomeClickUntil.value) {
event.preventDefault();
event.stopPropagation();
suppressHomeClickUntil.value = 0;
return;
}
router.push(page.to);
};
const cancelLocalDataReset = () => {
isLocalDataResetOpen.value = false;
};
const confirmLocalDataReset = async () => {
if (isClearingLocalData.value) {
return;
}
isClearingLocalData.value = true;
try {
await forceFrontendUpdateAndClearLocal();
} catch (error) {
isClearingLocalData.value = false;
console.error("Failed to clear local app data:", error);
}
};
watch(() => router.currentRoute.value.fullPath || router.currentRoute.value.path, cancelHomeLongPress);
onBeforeUnmount(cancelHomeLongPress);
</script>
<template>
@@ -45,13 +133,15 @@ const pages = {
<template v-for="(page, key) in pages" :key="key">
<div class="column has-text-centered">
<b-button
class="no-border-radius"
:type="$route.path.endsWith(page.to) ? 'is-info is-outlined is-light p-1' : 'p-1 is-light is-outlined'"
class="no-border-radius"
:data-testid="`mobile-footer-${key}`"
:type="isActiveFooterRoute(page.to) ? 'is-info is-outlined is-light p-1' : 'p-1 is-light is-outlined'"
size="is-normal"
@click="$router.push(page.to)"
v-on="footerButtonListeners(key)"
@click="handlePageClick($event, key, page)"
iconPack="fas"
expanded
:disabled="page.disabled"
:disabled="page.disabled"
>
<span>
<span><b-icon pack="fas" :icon="page.icon"></b-icon></span>
@@ -64,6 +154,12 @@ const pages = {
</div>
</b-field>
</section>
<LocalDataResetDialog
:model-value="isLocalDataResetOpen"
:busy="isClearingLocalData"
@update:model-value="cancelLocalDataReset"
@confirm="confirmLocalDataReset"
/>
</div>
</template>
@@ -84,4 +180,4 @@ const pages = {
border-top-right-radius: 4px;
background-color: #2c3e50;
}
</style>
</style>
+47 -5
View File
@@ -6,8 +6,9 @@ import {
invalidateFrontendCachesAfterError,
} from "@/services/frontendMaintenance.js";
const installBrowserMaintenanceMocks = () => {
const installBrowserMaintenanceMocks = ({ indexedDatabaseNames = [] } = {}) => {
const deletedCaches = [];
const deletedIndexedDatabases = [];
const update = vi.fn(async () => true);
const unregister = vi.fn(async () => true);
const getRegistrations = vi.fn(async () => [{ update, unregister }]);
@@ -18,6 +19,15 @@ const installBrowserMaintenanceMocks = () => {
return true;
}),
};
const indexedDB = {
databases: vi.fn(async () => indexedDatabaseNames.map((name) => ({ name }))),
deleteDatabase: vi.fn((databaseName) => {
deletedIndexedDatabases.push(databaseName);
const request = {};
queueMicrotask(() => request.onsuccess?.());
return request;
}),
};
Object.defineProperty(window, "caches", {
configurable: true,
@@ -27,8 +37,12 @@ const installBrowserMaintenanceMocks = () => {
configurable: true,
value: { getRegistrations },
});
Object.defineProperty(window, "indexedDB", {
configurable: true,
value: indexedDB,
});
return { caches, deletedCaches, getRegistrations, unregister, update };
return { caches, deletedCaches, deletedIndexedDatabases, getRegistrations, indexedDB, unregister, update };
};
describe("frontend maintenance", () => {
@@ -39,6 +53,9 @@ describe("frontend maintenance", () => {
afterEach(() => {
__resetFrontendMaintenanceForTests();
vi.restoreAllMocks();
Reflect.deleteProperty(window, "caches");
Reflect.deleteProperty(window, "indexedDB");
Reflect.deleteProperty(navigator, "serviceWorker");
});
it("invalidates browser caches and asks service workers to update after frontend errors", async () => {
@@ -66,8 +83,10 @@ describe("frontend maintenance", () => {
expect(mocks.update).toHaveBeenCalledTimes(1);
});
it("clears local state except the session token, unregisters service workers, clears caches, and reloads", async () => {
const mocks = installBrowserMaintenanceMocks();
it("clears all local state, unregisters service workers, clears caches, deletes IndexedDB, and reloads", async () => {
const mocks = installBrowserMaintenanceMocks({
indexedDatabaseNames: ["workbox-expiration", "truckwash-offline"],
});
const reload = vi.fn();
localStorage.setItem("token", "secret-token");
localStorage.setItem("draft", "local-value");
@@ -76,10 +95,33 @@ describe("frontend maintenance", () => {
const result = await forceFrontendUpdateAndClearLocal({ reload });
expect(result.cacheNames).toEqual(["pleno-api-cache", "pleno-website-cache"]);
expect(result.indexedDatabaseNames).toEqual(["workbox-expiration", "truckwash-offline"]);
expect(mocks.unregister).toHaveBeenCalledTimes(1);
expect(localStorage.getItem("token")).toBe("secret-token");
expect(mocks.deletedCaches).toEqual(["pleno-api-cache", "pleno-website-cache"]);
expect(mocks.deletedIndexedDatabases).toEqual(["workbox-expiration", "truckwash-offline"]);
expect(localStorage.getItem("token")).toBeNull();
expect(localStorage.getItem("draft")).toBeNull();
expect(sessionStorage.getItem("draft")).toBeNull();
expect(reload).toHaveBeenCalledTimes(1);
});
it("falls back to known IndexedDB names when database enumeration is unavailable", async () => {
const mocks = installBrowserMaintenanceMocks();
delete mocks.indexedDB.databases;
const result = await forceFrontendUpdateAndClearLocal({ reload: vi.fn() });
expect(result.indexedDatabaseNames).toEqual([
"workbox-expiration",
"workbox-background-sync",
"pleno",
"truckwash",
]);
expect(mocks.deletedIndexedDatabases).toEqual([
"workbox-expiration",
"workbox-background-sync",
"pleno",
"truckwash",
]);
});
});