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