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