Add tests for EditableTableColumn and fix tooltip multiline logic:

- Add unit tests for `EditableTableColumn` covering tooltip multiline behavior with `hoverHtml`.
- Update tooltip logic to use `isTooltipMultiline` computed property for slot checks.
- Adjust `OrderBookingsTable` to fix incorrect usage of `permissionCheckFunction` arguments.
- Optimize `RequestQueueProgress` state handling by removing unused visibility logic.
- Add unit test to ensure no recursive update errors occur during large request processing.
This commit is contained in:
Jeppe Bundgaard
2026-03-20 13:10:35 +01:00
parent f9bde54956
commit 43cf2b302d
5 changed files with 146 additions and 60 deletions
@@ -1,7 +1,9 @@
<script setup>
import {defineProps, ref} from 'vue';
import {computed, defineProps, ref, useSlots} from 'vue';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {BTooltip} from "buefy";
const slots = useSlots();
const props = defineProps({
object: Object,
loadList: Function,
@@ -71,6 +73,8 @@ const clickEdit = () => {
}
}
const isLoading = ref(false);
const hasHoverHtmlSlot = computed(() => Boolean(slots.hoverHtml));
const isTooltipMultiline = computed(() => Boolean(props.hoverHtml || hasHoverHtmlSlot.value));
//console.log(props);
</script>
@@ -110,7 +114,7 @@ const isLoading = ref(false);
<b-tooltip
type="is-light"
position="is-bottom"
:multilined="$slots.hoverHtml"
:multilined="isTooltipMultiline"
square>
<!-- Value Display -->
<span class="" v-if="!props.virtualColumn">{{ parseFunction ? parseFunction(props.object[props.column]) : props.object[props.column] }}</span>
@@ -134,4 +138,4 @@ const isLoading = ref(false);
background-color: #f5f5f5;
border-radius: 4px;
}
</style>
</style>
+17 -46
View File
@@ -35,7 +35,6 @@ const REQUEST_INSIGHT_DEFINITIONS = Object.freeze([
const SHIFT_MULTI_PRESS_WINDOW_MS = 700;
const SYSTEM_SEARCH_CLOSE_EVENT = "system-search:close";
const isVisible = ref(false);
const isExpanded = ref(REQUEST_QUEUE_CONFIG.inspector.expandedByDefault);
const isShortcutActivated = ref(false);
const nowMs = ref(Date.now());
@@ -44,20 +43,12 @@ const pingIsUnavailable = ref(false);
const lastShiftKeyPressedAtMs = ref(0);
const shiftKeyPressCount = ref(0);
let hideTimer = null;
let tickerTimer = null;
let pingTimer = null;
const hasOutstandingRequests = computed(() => requestQueueState.pending + requestQueueState.active > 0);
const processedRequests = computed(() => requestQueueState.batchCompleted + requestQueueState.batchFailed);
const missingPermissions = computed(() => requestQueueState.missingPermissions || []);
const hasStoredErrors = computed(() => (requestQueueState.errorRequests || []).length > 0);
const hasStoredPermissions = computed(() => missingPermissions.value.length > 0);
const hasStoredInspectorItems = computed(() => hasStoredErrors.value || hasStoredPermissions.value);
const shouldRenderForBatch = computed(() =>
hasStoredInspectorItems.value
|| requestQueueState.batchTotal >= REQUEST_QUEUE_CONFIG.progress.minBatchSizeToShow
);
const activeRequests = computed(() => requestQueueState.activeRequests || []);
const recentRequests = computed(() => requestQueueState.recentRequests || []);
const errorRequests = computed(() => requestQueueState.errorRequests || []);
@@ -157,13 +148,6 @@ const pingText = computed(() => {
return "Checking...";
});
const clearHideTimer = () => {
if (hideTimer !== null) {
clearTimeout(hideTimer);
hideTimer = null;
}
};
const formatDuration = (value) => {
const milliseconds = Math.max(0, Number(value) || 0);
if (milliseconds < 1000) {
@@ -215,6 +199,12 @@ const getMethodIconClass = (method) => METHOD_ICON_CLASS[String(method || "").to
const getActiveRequestElapsedMs = (request) => Math.max(0, nowMs.value - Number(request?.startedAt || nowMs.value));
const hasInsightEntryChanged = (currentEntry, nextEntry) =>
Number(currentEntry?.requestDurationMs || 0) !== Number(nextEntry?.requestDurationMs || 0)
|| Number(currentEntry?.completedAt || 0) !== Number(nextEntry?.completedAt || 0)
|| Number(currentEntry?.startedAt || 0) !== Number(nextEntry?.startedAt || 0)
|| Number(currentEntry?.queuedAt || 0) !== Number(nextEntry?.queuedAt || 0);
const requestInsights = computed(() => REQUEST_INSIGHT_DEFINITIONS.map((definition) => {
const matchedRequestFromRecent = recentRequests.value.find((request) =>
definition.matcher(String(request?.url || ""))
@@ -237,6 +227,7 @@ watch(recentRequests, (requests) => {
}
const nextHistory = { ...requestInsightHistory.value };
let hasChanges = false;
REQUEST_INSIGHT_DEFINITIONS.forEach((definition) => {
const matchedRequest = requests.find((request) =>
definition.matcher(String(request?.url || ""))
@@ -245,14 +236,22 @@ watch(recentRequests, (requests) => {
return;
}
nextHistory[definition.key] = {
const nextEntry = {
requestDurationMs: Math.max(0, Number(matchedRequest.requestDurationMs) || 0),
completedAt: matchedRequest.completedAt || null,
startedAt: matchedRequest.startedAt || null,
queuedAt: matchedRequest.queuedAt || null,
};
if (!hasInsightEntryChanged(nextHistory[definition.key], nextEntry)) {
return;
}
nextHistory[definition.key] = nextEntry;
hasChanges = true;
});
requestInsightHistory.value = nextHistory;
if (hasChanges) {
requestInsightHistory.value = nextHistory;
}
}, { immediate: true });
const resolvePingUrl = () => {
@@ -322,7 +321,6 @@ const handleWindowKeydown = (event) => {
if (event?.key === "Escape") {
if (isShortcutActivated.value) {
isShortcutActivated.value = false;
isVisible.value = false;
isExpanded.value = REQUEST_QUEUE_CONFIG.inspector.expandedByDefault;
}
return;
@@ -346,7 +344,6 @@ const handleWindowKeydown = (event) => {
closeSystemSearchForSuperuser();
isShortcutActivated.value = true;
isExpanded.value = true;
isVisible.value = true;
shiftKeyPressCount.value = 0;
}
@@ -433,31 +430,6 @@ const closeSystemSearchForSuperuser = () => {
}));
};
watch([hasOutstandingRequests, shouldRenderForBatch, hasStoredInspectorItems], ([hasOutstanding, shouldRender, hasInspectorItems]) => {
if (!shouldRender) {
clearHideTimer();
isVisible.value = false;
return;
}
if (hasOutstanding || hasInspectorItems) {
clearHideTimer();
isVisible.value = true;
return;
}
if (requestQueueState.batchTotal === 0) {
isVisible.value = false;
return;
}
clearHideTimer();
hideTimer = setTimeout(() => {
isVisible.value = false;
hideTimer = null;
}, REQUEST_QUEUE_CONFIG.progress.completedVisibleMs);
}, { immediate: true });
onMounted(() => {
window.addEventListener("keydown", handleWindowKeydown);
@@ -472,7 +444,6 @@ onMounted(() => {
});
onBeforeUnmount(() => {
clearHideTimer();
window.removeEventListener("keydown", handleWindowKeydown);
if (tickerTimer !== null) {
@@ -144,7 +144,6 @@ const onItemsClick = (event, booking) => {
const canEditBooking = (bookingobj) => {
// Admins can always edit
if (SessionUser.canAccessAdmin() && SessionUser.hasPermission('edit_bookings')) return true;
console.warn(bookingobj);
if (!!bookingobj.order_id) return false; // Only allow editing bookings without an order
if (SessionUser.hasPermission('edit_own_bookings')) return true;
}
@@ -273,7 +272,7 @@ const getColspan = () => {
column="datetime"
:parse-function="(value) => value ? `${value}` : 'Ingen dato valgt'"
:edit-function="SessionUser.objects.order_bookings.showEditObjectFieldForm"
:permissionCheckFunction="(bookingobj) => canEditBooking(booking)"
:permissionCheckFunction="() => canEditBooking(booking)"
/>
</p>
</td>
@@ -287,7 +286,7 @@ const getColspan = () => {
column="customer_number"
:parse-function="(value) => booking.customer_name ? `${booking.customer_name}` : 'Ukendt kunde'"
:edit-function="SessionUser.objects.order_bookings.showEditObjectFieldForm"
:permissionCheckFunction="(bookingobj) => canEditBooking(booking)"
:permissionCheckFunction="() => canEditBooking(booking)"
/>
</p>
</td>
@@ -301,7 +300,7 @@ const getColspan = () => {
column="reg_1"
:parse-function="(value) => value || 'Ingen registreret'"
:edit-function="SessionUser.objects.order_bookings.showEditObjectFieldForm"
:permissionCheckFunction="(bookingobj) => canEditBooking(booking)"
:permissionCheckFunction="() => canEditBooking(booking)"
/>
</p>
<p>
@@ -312,7 +311,7 @@ const getColspan = () => {
column="reg_2"
:parse-function="(value) => value || '-'"
:edit-function="SessionUser.objects.order_bookings.showEditObjectFieldForm"
:permissionCheckFunction="(bookingobj) => canEditBooking(booking)"
:permissionCheckFunction="() => canEditBooking(booking)"
/>
</p>
</td>
@@ -323,7 +322,7 @@ const getColspan = () => {
column="department"
:parse-function="(value) => getDepartmentName(parseInt(booking.department))"
:edit-function="SessionUser.objects.order_bookings.showEditObjectFieldForm"
:permissionCheckFunction="(bookingobj) => canEditBooking(booking)"
:permissionCheckFunction="() => canEditBooking(booking)"
/>
<!-- PO -->
<EditableTableColumn
@@ -332,7 +331,7 @@ const getColspan = () => {
column="po"
:parse-function="(value) => value || '-'"
:edit-function="SessionUser.objects.order_bookings.showEditObjectFieldForm"
:permissionCheckFunction="(bookingobj) => canEditBooking(booking)"
:permissionCheckFunction="() => canEditBooking(booking)"
/>
<!-- Reference -->
<EditableTableColumn
@@ -341,14 +340,14 @@ const getColspan = () => {
column="reference"
:parse-function="(value) => value || '-'"
:edit-function="SessionUser.objects.order_bookings.showEditObjectFieldForm"
:permissionCheckFunction="(bookingobj) => canEditBooking(booking)"
:permissionCheckFunction="() => canEditBooking(booking)"
/>
<!-- Pickup -->
<EditableTableColumn
:object="booking"
:loadList="loadList"
column="pickup"
:permissionCheckFunction="(bookingobj) => canEditBooking(booking)"
:permissionCheckFunction="() => canEditBooking(booking)"
:parse-function="(value) => value ? 'Ja' : 'Nej'"
:edit-function="SessionUser.objects.order_bookings.showEditObjectFieldForm"
/>
@@ -359,7 +358,7 @@ const getColspan = () => {
:hoverText="formatEntireItemsString(booking.items)"
:hoverHtml="true"
column="items"
:permissionCheckFunction="(bookingobj) => false"
:permissionCheckFunction="() => false"
:parse-function="(value) => formatSummaryString(booking.items)"
:edit-function="SessionUser.objects.order_bookings.showEditObjectFieldForm"
>
@@ -544,4 +543,4 @@ const getColspan = () => {
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
</style>
+86
View File
@@ -0,0 +1,86 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
const TooltipStub = {
name: "BTooltip",
props: {
type: String,
position: String,
multilined: Boolean,
square: Boolean,
},
template: `
<div class="tooltip-stub" :data-multilined="String(multilined)">
<slot></slot>
<slot name="content"></slot>
</div>
`,
};
const createBaseProps = () => ({
object: {
id: 1,
items: "Spot Free",
},
loadList: vi.fn(),
editFunction: vi.fn(() => Promise.resolve()),
column: "items",
componentWrapper: "div",
permissionCheckFunction: () => false,
});
describe("EditableTableColumn", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("passes a boolean true to b-tooltip multilined when hover html is enabled", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const wrapper = mount(EditableTableColumn, {
props: {
...createBaseProps(),
hoverHtml: true,
},
slots: {
hoverHtml: "<div>Spot Free\nLastbil</div>",
},
global: {
stubs: {
"b-tooltip": TooltipStub,
},
},
});
expect(wrapper.get(".tooltip-stub").attributes("data-multilined")).toBe("true");
expect(
warnSpy.mock.calls.some((call) =>
call.join(" ").includes('Invalid prop: type check failed for prop "multilined"')
)
).toBe(false);
expect(
errorSpy.mock.calls.some((call) =>
call.join(" ").includes('Invalid prop: type check failed for prop "multilined"')
)
).toBe(false);
});
it("keeps b-tooltip multilined false when hover html is disabled", () => {
const wrapper = mount(EditableTableColumn, {
props: {
...createBaseProps(),
hoverHtml: false,
},
global: {
stubs: {
"b-tooltip": TooltipStub,
},
},
});
expect(wrapper.get(".tooltip-stub").attributes("data-multilined")).toBe("false");
});
});
+26
View File
@@ -318,6 +318,32 @@ describe("RequestQueueProgress", () => {
expect(bookingsInsight.text()).toMatch(/now|s ago|m ago/);
});
it("does not emit recursive update errors while processing many requests", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mount(RequestQueueProgress);
await triggerShiftTriplePress();
await flushManyMicrotasks();
const requests = Array.from({ length: 30 }, (_, index) =>
enqueueRequest(
async () => ({ status: 200, data: { index } }),
{
method: "GET",
url: `/bulk-recursive-check-${index}`,
}
)
);
await Promise.all(requests);
await flushManyMicrotasks(30);
const hasRecursiveError = [...errorSpy.mock.calls, ...warnSpy.mock.calls]
.some((call) => call.join(" ").includes("Maximum recursive updates exceeded"));
expect(hasRecursiveError).toBe(false);
});
it("stores errors with request and response payloads and respects configured error limit", async () => {
__configureRequestQueueForTests({
errorHistoryLimit: 1,