Expand system search support for new object types and aliases, improve routing logic, and add unit tests:

- Enhance alias resolution with expanded mappings for objects like `attachment`, `item`, `discount`, `task`, and more.
- Refactor navigation resolver logic for better routing of `department_variable` and attachment-like payloads.
- Add unit tests to ensure registry coverage, alias routing behavior, and fallback handling.
This commit is contained in:
Jeppe Bundgaard
2026-03-13 00:16:58 +01:00
parent bc62040670
commit 965e79e5f5
2 changed files with 227 additions and 8 deletions
@@ -512,24 +512,56 @@ const embeddedRouteTarget = (payload: SearchPayload | null): SystemSearchNavigat
const toEntityAliasKey = (value: unknown): string => {
if (typeof value !== 'string') return '';
return value.trim().toLowerCase().replace(/[\s.-]+/g, '_');
return value
.trim()
.toLowerCase()
.replace(/[`"'()[\]{}]+/g, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
};
const ENTITY_TYPE_ALIAS_OVERRIDES: Record<string, EntityType> = {
attachment: 'orders',
attachments: 'orders',
file: 'orders',
files: 'orders',
document: 'orders',
documents: 'orders',
order_item: 'order_items',
order_items: 'order_items',
item: 'order_items',
items: 'order_items',
customer_discount: 'customer_discounts',
customer_discounts: 'customer_discounts',
discount: 'customer_discounts',
discounts: 'customer_discounts',
customer_fixed_price: 'customer_fixed_prices',
customer_fixed_prices: 'customer_fixed_prices',
fixed_price: 'customer_fixed_prices',
fixed_prices: 'customer_fixed_prices',
fixed_pricing: 'customer_fixed_prices',
config_variable: 'module_config',
config_variables: 'module_config',
module_config_variable: 'module_config',
module_config_variables: 'module_config',
department_variable: 'department_variables',
department_variables: 'department_variables',
question: 'department_selfserve_questions',
questions: 'department_selfserve_questions',
task: 'department_selfserve_tasks',
tasks: 'department_selfserve_tasks',
condition: 'department_selfserve_conditions',
conditions: 'department_selfserve_conditions',
condition_rule: 'department_selfserve_condition_rules',
condition_rules: 'department_selfserve_condition_rules',
vehicle_condition: 'department_selfserve_vehicle_conditions',
vehicle_conditions: 'department_selfserve_vehicle_conditions',
user: 'users',
users: 'users'
};
const ATTACHMENT_ALIAS_KEYS = new Set(['attachment', 'attachments', 'file', 'files', 'document', 'documents']);
const ENTITY_TYPE_ALIAS_LOOKUP: Map<string, EntityType> = (() => {
const lookup = new Map<string, EntityType>();
for (const entityType of SYSTEM_SEARCH_ENTITY_TYPES) {
@@ -551,6 +583,37 @@ const resolveEntityAlias = (value: unknown): EntityType | null => {
return ENTITY_TYPE_ALIAS_LOOKUP.get(key) ?? null;
};
const resolveObjectsMappedType = (
result: SearchResult,
payload: SearchPayload | null,
objectTypeKey: string,
rawObjectType: unknown
): EntityType | null => {
if (objectTypeKey === 'variable' || objectTypeKey === 'variables') {
const hasModuleVariableKeys = Boolean(
toScalar(readPayloadValue(payload, ['module'])) &&
toScalar(readPayloadValue(payload, ['variable', 'key']))
);
if (hasModuleVariableKeys) return 'module_config';
const hasDepartmentVariableKeys = extractDepartmentId(result, payload) !== null
&& Boolean(toScalar(readPayloadValue(payload, ['variable', 'key'])));
if (hasDepartmentVariableKeys) return 'department_variables';
}
return resolveEntityAlias(rawObjectType);
};
const isAttachmentLikePayload = (payload: SearchPayload | null): boolean => {
if (!payload) return false;
const content = readPayloadValue(payload, ['content', 'attachment', 'file']);
if (!content || typeof content !== 'object' || Array.isArray(content)) return false;
return ['image', 'document', 'other', 'relation', 'file', 'filename', 'path']
.some((key) => Object.prototype.hasOwnProperty.call(content as Record<string, unknown>, key));
};
const buildDelegatedObjectResult = (result: SearchResult, payload: SearchPayload | null, mappedType: EntityType): SearchResult => {
const delegatedId = toScalar(readPayloadValue(payload, [
'object_id',
@@ -591,7 +654,7 @@ const resolveObjects: NavigationResolver = (result, payload, access, context) =>
const rawObjectType = readPayloadValue(payload, ['object_type', 'objectType', 'entity_type', 'entityType', 'table', 'table_name', 'tableName', 'type']);
const objectTypeKey = toEntityAliasKey(rawObjectType);
const mappedType = resolveEntityAlias(rawObjectType);
const mappedType = resolveObjectsMappedType(result, payload, objectTypeKey, rawObjectType);
if (mappedType && mappedType !== 'objects') {
const delegatedResult = buildDelegatedObjectResult(result, payload, mappedType);
@@ -599,8 +662,10 @@ const resolveObjects: NavigationResolver = (result, payload, access, context) =>
if (delegatedTarget) return delegatedTarget;
}
if (objectTypeKey === 'attachment' || objectTypeKey === 'attachments') {
const orderId = toScalar(readPayloadValue(payload, ['order_id', 'orderId', 'object_id', 'objectId']));
const orderId = toScalar(readPayloadValue(payload, ['order_id', 'orderId', 'object_id', 'objectId']));
const looksLikeAttachment = ATTACHMENT_ALIAS_KEYS.has(objectTypeKey) || (Boolean(orderId) && isAttachmentLikePayload(payload));
if (looksLikeAttachment && orderId) {
const departmentId = extractDepartmentId(result, payload);
if (orderId && departmentId !== null && access.canAccessDepartment(departmentId)) {
return target('deep-link', `/admin/${departmentId}/modules/pos/orders/${encodeURIComponent(orderId)}`);
@@ -613,7 +678,12 @@ const resolveObjects: NavigationResolver = (result, payload, access, context) =>
}
}
if (objectTypeKey === 'config_variable' || objectTypeKey === 'config_variables') {
if (
objectTypeKey === 'config_variable'
|| objectTypeKey === 'config_variables'
|| objectTypeKey === 'module_config_variable'
|| objectTypeKey === 'module_config_variables'
) {
return resolveModuleConfig(result, payload, access, context);
}
@@ -775,6 +845,35 @@ const resolveCustomers: NavigationResolver = (result, payload, access, context)
return genericTarget(result, context);
};
const resolveCustomerPricing: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
const userId = toScalar(readPayloadValue(payload, ['user_id', 'userId']));
if (userId && access.canAccessSuperUser) {
return target('deep-link', `/superuser/users/${encodeURIComponent(userId)}/pricing`);
}
if (access.canAccessSuperUser) return target('module', '/superuser/users');
if (access.canAccessUser) return target('module', '/user/profile');
return genericTarget(result, context);
};
const resolveDepartmentVariables: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
const departmentId = extractDepartmentId(result, payload) ?? toNumber(result.entity_id);
if (departmentId !== null && access.canAccessSuperUser) {
return target('deep-link', `/superuser/departments/${departmentId}`);
}
if (departmentId !== null && access.canAccessDepartment(departmentId)) {
return target('deep-link', `/admin/${departmentId}`);
}
if (access.canAccessSuperUser) return target('module', '/superuser/departments');
if (access.canAccessAdmin) return target('module', '/admin');
return genericTarget(result, context);
};
const resolveSubusers: NavigationResolver = (result, payload, access, context) => {
const explicit = embeddedRouteTarget(payload);
if (explicit) return explicit;
@@ -903,10 +1002,15 @@ const resolveNavigationForType = (entityType: EntityType): NavigationResolver =>
return resolveCategories;
case 'customers':
return resolveCustomers;
case 'customer_discounts':
case 'customer_fixed_prices':
return resolveCustomerPricing;
case 'subusers':
return resolveSubusers;
case 'subuser_grants':
return resolveSubuserGrants;
case 'department_variables':
return resolveDepartmentVariables;
case 'plate_scanners':
case 'plate_scans':
return resolveScanners;
@@ -27,6 +27,13 @@ const userContext = {
canAccessDepartment: () => false,
};
const restrictedContext = {
canAccessSuperUser: false,
canAccessAdmin: false,
canAccessUser: false,
canAccessDepartment: () => false,
};
const row = (entityType, overrides = {}) => ({
entity_type: entityType,
entity_id: '11',
@@ -36,9 +43,9 @@ const row = (entityType, overrides = {}) => ({
});
describe('system search support registry coverage', () => {
it('contains exactly all 58 known entity types', () => {
expect(SYSTEM_SEARCH_ENTITY_TYPES).toHaveLength(58);
expect(new Set(SYSTEM_SEARCH_ENTITY_TYPES).size).toBe(58);
it('contains exactly all 59 known entity types', () => {
expect(SYSTEM_SEARCH_ENTITY_TYPES).toHaveLength(59);
expect(new Set(SYSTEM_SEARCH_ENTITY_TYPES).size).toBe(59);
const registryKeys = Object.keys(SYSTEM_SEARCH_SUPPORT_REGISTRY).sort();
const entityKeys = [...SYSTEM_SEARCH_ENTITY_TYPES].sort();
@@ -134,6 +141,114 @@ describe('system search navigation resolver behavior', () => {
expect(fxrates.to.path).toBe('/superuser/configuration/fxratesapi');
});
it('delegates object item aliases to order-item routing behavior', () => {
const nav = resolveSystemSearchNavigationTarget(
row('objects', {
entity_id: '998',
payload: {
object_type: 'item',
order_id: '34455',
department: '4',
},
}),
adminContext
);
expect(nav.strategy).toBe('deep-link');
expect(nav.to.path).toBe('/admin/4/modules/pos/orders/34455');
});
it('routes object discount/fixed-price aliases to user pricing routes', () => {
const withUser = resolveSystemSearchNavigationTarget(
row('objects', {
payload: {
object_type: 'discount',
user_id: '91',
},
}),
superuserContext
);
const noUser = resolveSystemSearchNavigationTarget(
row('objects', {
payload: {
object_type: 'fixed_pricing',
},
}),
superuserContext
);
expect(withUser.to.path).toBe('/superuser/users/91/pricing');
expect(noUser.to.path).toBe('/superuser/users');
});
it('routes noisy attachment-like object aliases to the linked order page', () => {
const nav = resolveSystemSearchNavigationTarget(
row('objects', {
payload: {
object_type: '`DOCUMENT`',
order_id: '34455',
department_id: 4,
},
}),
adminContext
);
expect(nav.strategy).toBe('deep-link');
expect(nav.to.path).toBe('/admin/4/modules/pos/orders/34455');
});
it('routes object question/task/condition aliases to self-serve module pages', () => {
const questionNav = resolveSystemSearchNavigationTarget(
row('objects', {
payload: { object_type: 'question', department_id: 4 },
}),
adminContext
);
const taskNav = resolveSystemSearchNavigationTarget(
row('objects', {
payload: { object_type: 'task', department_id: 4 },
}),
adminContext
);
const conditionNav = resolveSystemSearchNavigationTarget(
row('objects', {
payload: { object_type: 'condition', department_id: 4 },
}),
adminContext
);
expect(questionNav.to.path).toBe('/admin/4/modules/self-serve/questions');
expect(taskNav.to.path).toBe('/admin/4/modules/self-serve/tasks');
expect(conditionNav.to.path).toBe('/admin/4/modules/self-serve/conditions');
});
it('keeps generic fallback when object subtype lacks required routing context', () => {
const nav = resolveSystemSearchNavigationTarget(
row('objects', {
payload: { object_type: 'discount' },
}),
restrictedContext
);
expect(nav.strategy).toBe('generic');
expect(nav.to.path).toContain('/search/system/record/customer_discounts/11');
});
it('routes department variables by role and department context', () => {
const adminNav = resolveSystemSearchNavigationTarget(
row('department_variables', {
payload: { department_id: 4 },
}),
adminContext
);
const superNav = resolveSystemSearchNavigationTarget(
row('department_variables', {
payload: { department_id: 4 },
}),
superuserContext
);
expect(adminNav.to.path).toBe('/admin/4');
expect(superNav.to.path).toBe('/superuser/departments/4');
});
it('handles missing payload safely and still opens generic detail', () => {
const nav = resolveSystemSearchNavigationTarget(
row('module_action_logs', { payload: null }),