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:
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+10
-11
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user