Add local data reset dialog and enhance footer navigation with long press functionality
This commit is contained in:
@@ -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 på 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>
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user