From b28b95d40fa403581585317d20e2973f7192ae19 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Fri, 13 Mar 2026 13:47:16 +0100 Subject: [PATCH] Add unit tests and keyboard navigation enhancements for system search UI: - Add unit tests for result sorting, grouping, and keyboard state synchronization. - Implement keyboard navigation improvements for selections and group/result highlighting. - Update sidebar and result pane interaction with visual indicators and scroll handling. - Refactor payload extraction logic for nested keys and improve related action routing. --- .../menu/NavigationMenuGlobalSearch.vue | 182 ++++++++++++++---- .../page/headers/menu/systemSearchSupport.ts | 15 +- .../headers/menu/systemSearchUiSupport.ts | 113 +++++++++++ .../unit/system-search-modal-contract.spec.js | 26 ++- .../system-search-support-registry.spec.js | 25 +++ tests/unit/system-search-ui-support.spec.js | 115 +++++++++++ 6 files changed, 439 insertions(+), 37 deletions(-) create mode 100644 src/components/viewport/page/headers/menu/systemSearchUiSupport.ts create mode 100644 tests/unit/system-search-ui-support.spec.js diff --git a/src/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue b/src/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue index ba20fd66..feaee749 100644 --- a/src/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue +++ b/src/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue @@ -73,6 +73,8 @@ const method = ref('POST'); const query = ref(''); const queryInputRef = ref(null); const searchShellRef = ref(null); +const sidebarGroupButtonRefs = new Map(); +const resultCardRefs = new Map(); const includeTypes = ref([]); const excludeTypes = ref([]); const includeAssociations = ref(true); @@ -188,11 +190,15 @@ watch([selectedGroupTotal, limit], () => { if (offset.value > maxOffset) offset.value = maxOffset; }); -const tryFocusAndSelectQuery = () => { +const resolveQueryInputElement = (): HTMLInputElement | null => { const buefyInput = queryInputRef.value as { $el?: HTMLElement; $refs?: { input?: HTMLInputElement } } | null; - const input = buefyInput?.$refs?.input + return buefyInput?.$refs?.input ?? buefyInput?.$el?.querySelector('input') ?? null; +}; + +const tryFocusAndSelectQuery = () => { + const input = resolveQueryInputElement(); if (!input) return; input.focus(); input.select(); @@ -216,6 +222,11 @@ const focusQueryFromKeyboardNavigation = () => { focusQueryInput(); }; +const isQueryInputTarget = (target: EventTarget | null): boolean => { + const input = resolveQueryInputElement(); + return Boolean(input && target === input); +}; + const DOUBLE_SHIFT_MAX_INTERVAL_MS = 420; const lastStandaloneShiftReleaseAt = ref(0); const shiftIsDown = ref(false); @@ -362,14 +373,52 @@ const toBoolean = (value: unknown): boolean | null => { }; const readPayloadValue = (payload: SearchPayload | null, keys: string[]): unknown => { if (!payload) return null; + const readNestedPath = (source: unknown, path: string): unknown => { + const segments = path.split('.').filter(Boolean); + if (!segments.length) return null; + let current: unknown = source; + for (const segment of segments) { + if (Array.isArray(current)) { + const index = Number.parseInt(segment, 10); + if (Number.isNaN(index) || index < 0 || index >= current.length) return null; + current = current[index]; + continue; + } + if (!current || typeof current !== 'object') return null; + if (!Object.prototype.hasOwnProperty.call(current as Record, segment)) return null; + current = (current as Record)[segment]; + } + return current; + }; + for (const key of keys) { if (Object.prototype.hasOwnProperty.call(payload, key)) return payload[key]; + if (key.includes('.')) { + const nested = readNestedPath(payload, key); + if (nested !== null && typeof nested !== 'undefined') return nested; + } } return null; }; const OBJECT_SUBTYPE_KEYS = ['object_type', 'objectType', 'entity_type', 'entityType', 'table', 'table_name', 'tableName', 'type']; const CANCELLED_STATUS_ALIASES = new Set(['cancelled', 'canceled', 'cancel', 'aflyst', 'annulleret']); +const CUSTOMER_NUMBER_KEYS = [ + 'customer_number', + 'customerNumber', + 'customer_id', + 'CustomerId', + 'customer_context.customer_number', + 'customerContext.customerNumber' +]; +const LINKED_USER_ID_KEYS = [ + 'user_id', + 'userId', + 'customer_user_id', + 'customerUserId', + 'customer_context.user_id', + 'customerContext.userId' +]; const normalizeEntityAliasForUi = (alias: string): string => { if (!alias) return ''; @@ -562,10 +611,18 @@ const extractDepartmentIdFromPayload = (result: SearchResult, payload: SearchPay return toNumber(payload['department_id']) ?? toNumber(payload['departmentId']) ?? toNumber(payload['department']); }; +const extractCustomerNumberFromAssociationReason = (result: SearchResult): number | null => { + const match = /(?:^|[\s,;])customer:(\d+)(?:$|[\s,;])/.exec(toCleanString(result.association_reason)); + return match ? toNumber(match[1]) : null; +}; + const extractCustomerNumberFromPayload = (result: SearchResult, payload: SearchPayload | null): number | null => { if (typeof result.customer_number === 'number' && Number.isFinite(result.customer_number)) return result.customer_number; - if (!payload) return null; - return toNumber(payload['customer_number']) ?? toNumber(payload['customerNumber']) ?? toNumber(payload['customer_id']) ?? toNumber(payload['CustomerId']); + return toInteger(readPayloadValue(payload, CUSTOMER_NUMBER_KEYS)) ?? extractCustomerNumberFromAssociationReason(result); +}; + +const extractUserIdFromPayload = (row: SearchResult, payload: SearchPayload | null, entityAlias: string): number | null => { + return toInteger(readPayloadValue(payload, LINKED_USER_ID_KEYS)) ?? (entityAlias === 'users' ? toInteger(row.entity_id) : null); }; const extractVehicleRegistrationFromPayload = (row: SearchResult, payload: SearchPayload | null): string | null => { @@ -805,7 +862,7 @@ const runSearch = async (nextOffset?: number) => { offset.value = Math.min(requestedOffset, maxOffset); await nextTick(); if (visibleResultCardKeys.value.length) { - selectFirstVisibleResult(); + highlightFirstVisibleResult('query'); } else { focusKeyboardPane('sidebar'); } @@ -931,7 +988,7 @@ const resolveModuleConfigRequestPath = (moduleValue: unknown): string | null => }; const resolveUserIdForDiscountEdit = async (payload: SearchPayload | null, customerNumber: number | null): Promise => { - const payloadUserId = toInteger(readPayloadValue(payload, ['user_id', 'userId'])); + const payloadUserId = toInteger(readPayloadValue(payload, LINKED_USER_ID_KEYS)); if (payloadUserId !== null) return payloadUserId; if (customerNumber === null || !canCacheManage.value) return null; @@ -1363,7 +1420,7 @@ const visibleResultCards = computed(() => visibleResults.va const resolvedAlias = resolveUiEntityAlias(row, payload); const createdAt = extractCreatedAt(payload); const customerNumber = extractCustomerNumberFromPayload(row, payload); - const userId = toInteger(readPayloadValue(payload, ['user_id', 'userId'])) ?? (resolvedAlias === 'users' ? toInteger(row.entity_id) : null); + const userId = extractUserIdFromPayload(row, payload, resolvedAlias); const departmentId = extractDepartmentIdFromPayload(row, payload); const orderId = extractOrderIdFromPayload(row, payload, resolvedAlias); const vehicleRegistration = extractVehicleRegistrationFromPayload(row, payload); @@ -1403,6 +1460,28 @@ const keyboardState = ref({ selectedResultKey: null }); +const setTrackedElementRef = (target: Map, key: string, element: Element | null) => { + if (element instanceof HTMLElement) { + target.set(key, element); + return; + } + target.delete(key); +}; + +const setSidebarGroupButtonRef = (key: string, element: Element | null) => { + setTrackedElementRef(sidebarGroupButtonRefs, key, element); +}; + +const setResultCardRef = (key: string, element: Element | null) => { + setTrackedElementRef(resultCardRefs, key, element); +}; + +const scrollTrackedElementIntoView = (element: HTMLElement | null) => { + nextTick(() => { + element?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }); +}; + const sidebarGroupKeys = computed(() => sidebarGroups.value.map((group) => group.key)); const visibleResultCardKeys = computed(() => visibleResultCards.value.map((card) => getCardKey(card))); @@ -1429,10 +1508,17 @@ const selectResultCard = (card: VisibleResultCard) => { syncKeyboardState({ selectedResultKey: getCardKey(card) }); }; -const selectFirstVisibleResult = () => { +const highlightFirstVisibleResult = (pane: SystemSearchKeyboardPane = keyboardState.value.pane) => { if (!visibleResultCardKeys.value.length) return; + syncKeyboardState({ + pane, + selectedResultKey: visibleResultCardKeys.value[0] ?? null + }); +}; + +const selectFirstVisibleResult = () => { focusKeyboardPane('results'); - syncKeyboardState({ selectedResultKey: visibleResultCardKeys.value[0] ?? null }); + highlightFirstVisibleResult('results'); }; const selectedResultCard = computed(() => { @@ -1452,6 +1538,12 @@ const onSearchShellKeydown = (event: KeyboardEvent) => { if (!isModalOpen.value || activeSetting.value) return; if (event.defaultPrevented) return; if (event.altKey || event.ctrlKey || event.metaKey) return; + if (isQueryInputTarget(event.target) && event.key === 'ArrowDown') { + if (!visibleResultCardKeys.value.length) return; + event.preventDefault(); + selectFirstVisibleResult(); + return; + } if (isTextEntryTarget(event.target)) return; if (event.key === 'ArrowLeft') { @@ -1652,9 +1744,25 @@ watch(selectedGroup, (groupKey) => { syncKeyboardState({ selectedSidebarKey: groupKey }); }); +watch( + [() => keyboardState.value.pane, () => keyboardState.value.selectedSidebarKey], + ([pane, selectedSidebarKey]) => { + if (pane !== 'sidebar' || !selectedSidebarKey) return; + scrollTrackedElementIntoView(sidebarGroupButtonRefs.get(selectedSidebarKey) ?? null); + } +); + +watch( + [() => keyboardState.value.pane, () => keyboardState.value.selectedResultKey], + ([pane, selectedResultKey]) => { + if (pane !== 'results' || !selectedResultKey) return; + scrollTrackedElementIntoView(resultCardRefs.get(selectedResultKey) ?? null); + } +); + const canShowCustomerAction = (card: VisibleResultCard): boolean => { if (card.relatedTargets.customer) return true; - return SessionUser.canAccessSuperUser() && card.customerNumber !== null; + return SessionUser.canAccessSuperUser() && (card.customerNumber !== null || card.userId !== null); }; const canShowAttachmentsAction = (card: VisibleResultCard): boolean => ( @@ -1758,12 +1866,6 @@ const openCustomer = async (card: VisibleResultCard) => { setCardActionLoading(card, 'customer', true); error.value = ''; try { - if (card.relatedTargets.customer) { - await router.push(card.relatedTargets.customer.to); - isModalOpen.value = false; - return; - } - const resolvedUserId = card.userId ?? await resolveUserIdForDiscountEdit(card.viewModel.rawPayload, card.customerNumber); if (resolvedUserId !== null) { const userTarget = resolveSyntheticNavigationTarget( @@ -1782,6 +1884,12 @@ const openCustomer = async (card: VisibleResultCard) => { } } + if (card.relatedTargets.customer) { + await router.push(card.relatedTargets.customer.to); + isModalOpen.value = false; + return; + } + if (card.customerNumber !== null) { const customerTarget = resolveSyntheticNavigationTarget( 'customers', @@ -1975,9 +2083,10 @@ const asJson = (v: unknown) => { - Arrow keys: sidebar + Groups

{{ t('global_search.groups.description') }}

@@ -1985,6 +2094,7 @@ const asJson = (v: unknown) => {