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.
This commit is contained in:
Jeppe Bundgaard
2026-03-13 13:47:16 +01:00
parent cc260cf6c3
commit b28b95d40f
6 changed files with 439 additions and 37 deletions
@@ -73,6 +73,8 @@ const method = ref<SearchMethod>('POST');
const query = ref('');
const queryInputRef = ref<unknown>(null);
const searchShellRef = ref<HTMLElement | null>(null);
const sidebarGroupButtonRefs = new Map<string, HTMLElement>();
const resultCardRefs = new Map<string, HTMLElement>();
const includeTypes = ref<EntityType[]>([]);
const excludeTypes = ref<EntityType[]>([]);
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<string, unknown>, segment)) return null;
current = (current as Record<string, unknown>)[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<number | null> => {
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<VisibleResultCard[]>(() => 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<SystemSearchKeyboardState>({
selectedResultKey: null
});
const setTrackedElementRef = (target: Map<string, HTMLElement>, 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<VisibleResultCard | null>(() => {
@@ -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) => {
<span
v-if="keyboardState.pane === 'sidebar'"
class="navigation-pane-indicator navigation-pane-indicator--sidebar"
title="Arrow keys navigate groups"
>
<b-icon icon="arrows-alt-v" pack="fas" size="is-small" />
Arrow keys: sidebar
Groups
</span>
</div>
<p class="is-size-7 has-text-grey mb-2">{{ t('global_search.groups.description') }}</p>
@@ -1985,6 +2094,7 @@ const asJson = (v: unknown) => {
<button
v-for="group in sidebarGroups"
:key="`sidebar-group-${group.key}`"
:ref="(element) => setSidebarGroupButtonRef(group.key, element)"
type="button"
class="button is-fullwidth mb-2 sidebar-group-button"
:class="[
@@ -2090,9 +2200,10 @@ const asJson = (v: unknown) => {
<span
v-if="keyboardState.pane === 'results'"
class="navigation-pane-indicator navigation-pane-indicator--results"
title="Arrow keys navigate results"
>
<b-icon icon="arrows-alt-v" pack="fas" size="is-small" />
Arrow keys: results
Results
</span>
<b-button class="search-meta-toggle" type="is-text" size="is-small" @click="showMeta = !showMeta">{{ showMeta ? t('global_search.meta.hide') : t('global_search.meta.show') }}</b-button>
</div>
@@ -2138,10 +2249,11 @@ const asJson = (v: unknown) => {
<article
v-for="card in visibleResultCards"
:key="`${card.row.entity_type}-${card.row.entity_id}-${card.row.score}`"
:ref="(element) => setResultCardRef(getCardKey(card), element)"
class="result-item"
:class="{
'result-item--cancelled': card.isCancelledBooking,
'result-item--selected': keyboardState.pane === 'results' && keyboardState.selectedResultKey === getCardKey(card)
'result-item--selected': keyboardState.pane !== 'sidebar' && keyboardState.selectedResultKey === getCardKey(card)
}"
@click="selectResultCard(card)"
>
@@ -2482,9 +2594,10 @@ const asJson = (v: unknown) => {
.search-sidebar-footer { border-top: 1px solid var(--search-border); background: transparent; }
.sidebar-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 4px;
}
.sidebar-group-button { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.sidebar-group-button--selected {
@@ -2643,23 +2756,24 @@ const asJson = (v: unknown) => {
.navigation-pane-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
font-weight: 700;
line-height: 1;
gap: 4px;
padding: 0;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
color: var(--search-text-subtle);
}
.navigation-pane-indicator :deep(.icon) {
color: currentColor;
}
.navigation-pane-indicator--sidebar {
color: #1d4ed8;
background: rgba(37, 99, 235, 0.12);
border: 1px solid rgba(37, 99, 235, 0.18);
color: var(--search-text-subtle);
max-width: 100%;
align-self: flex-start;
}
.navigation-pane-indicator--results {
color: #0f766e;
background: rgba(13, 148, 136, 0.12);
border: 1px solid rgba(13, 148, 136, 0.18);
color: var(--search-text-subtle);
}
.search-meta-toggle {
white-space: nowrap;
@@ -1080,7 +1080,20 @@ const resolveCategories: NavigationResolver = (result, payload, access, context)
const resolveCustomers: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
if (access.canAccessSuperUser) return target('module', '/superuser/customers');
const customerUserId = toScalar(readPayloadValue(payload, [
'customer_user_id',
'customerUserId',
'customer_context.user_id',
'customerContext.userId',
'user_id',
'userId'
]));
if (access.canAccessSuperUser) {
if (customerUserId) {
return target('deep-link', `/superuser/users/${encodeURIComponent(customerUserId)}`);
}
return target('module', '/superuser/customers');
}
if (access.canAccessUser) return target('module', '/user/profile');
return genericTarget(result, context);
};
@@ -0,0 +1,113 @@
import {
SYSTEM_SEARCH_ENTITY_TYPES,
type SearchResult,
} from '@/components/viewport/page/headers/menu/systemSearchSupport';
export type SystemSearchKeyboardPane = 'query' | 'sidebar' | 'results';
export type SystemSearchKeyboardState = {
pane: SystemSearchKeyboardPane;
selectedSidebarKey: string | null;
selectedResultKey: string | null;
};
const UI_PRIORITY_PREFIX = ['customers', 'orders', 'order_bookings'] as const;
export const SYSTEM_SEARCH_UI_ENTITY_ORDER = [
...UI_PRIORITY_PREFIX,
...SYSTEM_SEARCH_ENTITY_TYPES.filter((entityType) => !UI_PRIORITY_PREFIX.includes(entityType))
] as const;
const SYSTEM_SEARCH_UI_ENTITY_INDEX = new Map(
SYSTEM_SEARCH_UI_ENTITY_ORDER.map((entityType, index) => [entityType, index])
);
const getFirstKey = (keys: string[]): string | null => (keys.length ? keys[0] : null);
const normalizeSelectedKey = (
keys: string[],
selectedKey: string | null,
fallbackKey?: string | null
): string | null => {
if (selectedKey && keys.includes(selectedKey)) return selectedKey;
if (fallbackKey && keys.includes(fallbackKey)) return fallbackKey;
return getFirstKey(keys);
};
const getEntityOrderIndex = (entityType: string): number => (
SYSTEM_SEARCH_UI_ENTITY_INDEX.get(entityType as (typeof SYSTEM_SEARCH_UI_ENTITY_ORDER)[number])
?? SYSTEM_SEARCH_UI_ENTITY_ORDER.length
);
export const sortSystemSearchGroupKeys = (groupKeys: string[]): string[] => [...groupKeys].sort((left, right) => {
const byKnownOrder = getEntityOrderIndex(left) - getEntityOrderIndex(right);
if (byKnownOrder !== 0) return byKnownOrder;
return left.localeCompare(right);
});
export const sortSystemSearchResults = <T extends SearchResult>(rows: T[]): T[] => {
if (rows.length < 2) return [...rows];
const rowsByGroup = new Map<string, T[]>();
for (const row of rows) {
const key = String(row.entity_type);
const existing = rowsByGroup.get(key);
if (existing) existing.push(row);
else rowsByGroup.set(key, [row]);
}
const orderedKeys = sortSystemSearchGroupKeys([...rowsByGroup.keys()]);
return orderedKeys.flatMap((key) => rowsByGroup.get(key) ?? []);
};
export const groupSystemSearchResults = <T extends SearchResult>(rows: T[]): Record<string, T[]> => {
const rowsByGroup = new Map<string, T[]>();
for (const row of rows) {
const key = String(row.entity_type);
const existing = rowsByGroup.get(key);
if (existing) existing.push(row);
else rowsByGroup.set(key, [row]);
}
return Object.fromEntries(
sortSystemSearchGroupKeys([...rowsByGroup.keys()]).map((key) => [key, rowsByGroup.get(key) ?? []])
) as Record<string, T[]>;
};
export const moveSystemSearchSelection = (
keys: string[],
selectedKey: string | null,
delta: -1 | 1
): string | null => {
if (!keys.length) return null;
const currentIndex = selectedKey ? keys.indexOf(selectedKey) : -1;
if (currentIndex < 0) return keys[0] ?? null;
const safeCurrentIndex = currentIndex;
const nextIndex = Math.min(keys.length - 1, Math.max(0, safeCurrentIndex + delta));
return keys[nextIndex] ?? null;
};
export const syncSystemSearchKeyboardState = (
state: SystemSearchKeyboardState,
sidebarKeys: string[],
resultKeys: string[],
fallbackSidebarKey: string | null = 'all'
): SystemSearchKeyboardState => {
const selectedSidebarKey = normalizeSelectedKey(sidebarKeys, state.selectedSidebarKey, fallbackSidebarKey);
const selectedResultKey = normalizeSelectedKey(resultKeys, state.selectedResultKey);
let pane = state.pane;
if (pane === 'results' && !selectedResultKey) {
pane = selectedSidebarKey ? 'sidebar' : 'query';
}
if (pane === 'sidebar' && !selectedSidebarKey) {
pane = selectedResultKey ? 'results' : 'query';
}
return {
pane,
selectedSidebarKey,
selectedResultKey
};
};
@@ -20,6 +20,11 @@ describe('advanced system search modal UI contract', () => {
expect(modalSource).toContain('const keyboardState = ref<SystemSearchKeyboardState>');
expect(modalSource).toContain('const onSearchShellKeydown = (event: KeyboardEvent) =>');
expect(modalSource).toContain('const focusQueryFromKeyboardNavigation = () =>');
expect(modalSource).toContain('const isQueryInputTarget = (target: EventTarget | null): boolean =>');
expect(modalSource).toContain('const setSidebarGroupButtonRef = (key: string, element: Element | null) =>');
expect(modalSource).toContain('const setResultCardRef = (key: string, element: Element | null) =>');
expect(modalSource).toContain("const highlightFirstVisibleResult = (pane: SystemSearchKeyboardPane = keyboardState.value.pane) =>");
expect(modalSource).toContain("scrollIntoView({ block: 'nearest', inline: 'nearest' })");
expect(modalSource).toContain("@keydown.capture=\"onSearchShellKeydown\"");
expect(modalSource).toContain('v-for="card in visibleResultCards"');
expect(modalSource).toContain('card.viewModel.keyFields');
@@ -37,9 +42,11 @@ describe('advanced system search modal UI contract', () => {
expect(modalSource).toContain('result-item--selected');
expect(modalSource).toContain('sidebar-group-button--selected');
expect(modalSource).toContain('sidebar-group-button--active-pane');
expect(modalSource).toContain('Arrow keys: sidebar');
expect(modalSource).toContain('Arrow keys: results');
expect(modalSource).toContain('title="Arrow keys navigate groups"');
expect(modalSource).toContain('title="Arrow keys navigate results"');
expect(modalSource).toContain('result-object-id');
expect(modalSource).toContain(':ref="(element) => setSidebarGroupButtonRef(group.key, element)"');
expect(modalSource).toContain(':ref="(element) => setResultCardRef(getCardKey(card), element)"');
expect(modalSource).toContain('related-actions-panel');
expect(modalSource).toContain('v-if="hasRelatedActions(card)"');
expect(modalSource).toContain("@click=\"openRelatedTarget(card, 'order')\"");
@@ -51,13 +58,28 @@ describe('advanced system search modal UI contract', () => {
expect(supportSource).toContain('export const normalizePayload');
});
it('resolves customer actions from nested payload fields before falling back to generic customer routes', () => {
expect(modalSource).toContain("'customer_context.customer_number'");
expect(modalSource).toContain("'customer_context.user_id'");
expect(modalSource).toContain('const extractUserIdFromPayload = (row: SearchResult, payload: SearchPayload | null, entityAlias: string): number | null =>');
expect(modalSource).toContain('const resolvedUserId = card.userId ?? await resolveUserIdForDiscountEdit(card.viewModel.rawPayload, card.customerNumber);');
const resolveUserIndex = modalSource.indexOf('const resolvedUserId = card.userId ?? await resolveUserIdForDiscountEdit(card.viewModel.rawPayload, card.customerNumber);');
const relatedTargetIndex = modalSource.indexOf('if (card.relatedTargets.customer) {');
expect(resolveUserIndex).toBeGreaterThan(-1);
expect(relatedTargetIndex).toBeGreaterThan(resolveUserIndex);
});
it('registers a global double-shift shortcut that opens the search modal', () => {
expect(modalSource).toContain('const DOUBLE_SHIFT_MAX_INTERVAL_MS = 420');
expect(modalSource).toContain('const onGlobalKeyDown = (event: KeyboardEvent) =>');
expect(modalSource).toContain('const onGlobalKeyUp = (event: KeyboardEvent) =>');
expect(modalSource).toContain("selectFirstVisibleResult();");
expect(modalSource).toContain("highlightFirstVisibleResult('query');");
expect(modalSource).toContain("focusKeyboardPane('sidebar');");
expect(modalSource).toContain('focusQueryFromKeyboardNavigation();');
expect(modalSource).toContain("isQueryInputTarget(event.target) && event.key === 'ArrowDown'");
expect(modalSource).toContain("if (event.key === 'Shift')");
expect(modalSource).toContain('openSearchFromShortcut();');
expect(modalSource).toContain("window.addEventListener('keydown', onGlobalKeyDown)");
@@ -208,6 +208,31 @@ describe('system search navigation resolver behavior', () => {
expect(noUser.to.path).toBe('/superuser/users');
});
it('routes customer object aliases to the linked superuser user page when customer_user_id is present', () => {
const nav = resolveSystemSearchNavigationTarget(
row('objects', {
entity_id: '1952',
payload: {
object_type: 'customer',
id: 1952,
customer_number: 12345679,
display_name: 'Demo',
customer_name: '(TEST) Pleno Vognmandsforretning',
customer_context: {
customer_number: 12345679,
user_id: 1953,
name: '(TEST) Pleno Vognmandsforretning',
},
customer_user_id: 1953,
},
}),
superuserContext
);
expect(nav.strategy).toBe('deep-link');
expect(nav.to.path).toBe('/superuser/users/1953');
});
it('routes noisy attachment-like object aliases to the linked order page', () => {
const nav = resolveSystemSearchNavigationTarget(
row('objects', {
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import { SYSTEM_SEARCH_ENTITY_TYPES } from '@/components/viewport/page/headers/menu/systemSearchSupport.ts';
import {
SYSTEM_SEARCH_UI_ENTITY_ORDER,
groupSystemSearchResults,
moveSystemSearchSelection,
sortSystemSearchResults,
sortSystemSearchGroupKeys,
syncSystemSearchKeyboardState,
} from '@/components/viewport/page/headers/menu/systemSearchUiSupport.ts';
const row = (entityType, entityId) => ({
entity_type: entityType,
entity_id: String(entityId),
title: `${entityType}-${entityId}`,
score: 100,
});
describe('system search UI ordering support', () => {
it('keeps customers, orders, and order bookings at the front of the UI order while covering every known type', () => {
expect(SYSTEM_SEARCH_UI_ENTITY_ORDER.slice(0, 3)).toEqual(['customers', 'orders', 'order_bookings']);
expect(new Set(SYSTEM_SEARCH_UI_ENTITY_ORDER).size).toBe(SYSTEM_SEARCH_UI_ENTITY_ORDER.length);
expect([...SYSTEM_SEARCH_UI_ENTITY_ORDER].sort()).toEqual([...SYSTEM_SEARCH_ENTITY_TYPES].sort());
});
it('sorts flat results by UI category order while preserving order inside each category', () => {
const sorted = sortSystemSearchResults([
row('orders', 2),
row('module_config', 9),
row('customers', 7),
row('order_bookings', 4),
row('orders', 5),
row('notifications', 3),
]);
expect(sorted.map((entry) => `${entry.entity_type}:${entry.entity_id}`)).toEqual([
'customers:7',
'orders:2',
'orders:5',
'order_bookings:4',
'module_config:9',
'notifications:3',
]);
});
it('orders grouped keys with unknown values after known entity types', () => {
expect(sortSystemSearchGroupKeys(['orders', 'mystery', 'customers', 'order_bookings'])).toEqual([
'customers',
'orders',
'order_bookings',
'mystery',
]);
});
it('builds grouped results in the same UI order as the flat list', () => {
const grouped = groupSystemSearchResults([
row('orders', 2),
row('customers', 7),
row('order_bookings', 4),
row('orders', 5),
]);
expect(Object.keys(grouped)).toEqual(['customers', 'orders', 'order_bookings']);
expect(grouped.orders.map((entry) => entry.entity_id)).toEqual(['2', '5']);
});
});
describe('system search keyboard selection support', () => {
it('defaults sidebar and result selection to the active group and first result', () => {
const synced = syncSystemSearchKeyboardState(
{
pane: 'results',
selectedSidebarKey: null,
selectedResultKey: null,
},
['all', 'customers', 'orders'],
['customers-7', 'orders-2'],
'all'
);
expect(synced).toEqual({
pane: 'results',
selectedSidebarKey: 'all',
selectedResultKey: 'customers-7',
});
});
it('falls back to sidebar mode when the selected result disappears', () => {
const synced = syncSystemSearchKeyboardState(
{
pane: 'results',
selectedSidebarKey: 'customers',
selectedResultKey: 'missing',
},
['all', 'customers', 'orders'],
[],
'customers'
);
expect(synced).toEqual({
pane: 'sidebar',
selectedSidebarKey: 'customers',
selectedResultKey: null,
});
});
it('moves selection up and down without wrapping past the list edges', () => {
const keys = ['customers', 'orders', 'order_bookings'];
expect(moveSystemSearchSelection(keys, null, 1)).toBe('customers');
expect(moveSystemSearchSelection(keys, 'customers', -1)).toBe('customers');
expect(moveSystemSearchSelection(keys, 'orders', 1)).toBe('order_bookings');
expect(moveSystemSearchSelection(keys, 'order_bookings', 1)).toBe('order_bookings');
});
});