diff --git a/src/app/core/_components/tables/ht-table/ht-table.component.ts b/src/app/core/_components/tables/ht-table/ht-table.component.ts index 3060f350..9d7ea518 100644 --- a/src/app/core/_components/tables/ht-table/ht-table.component.ts +++ b/src/app/core/_components/tables/ht-table/ht-table.component.ts @@ -22,7 +22,7 @@ import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { MatSort, SortDirection } from '@angular/material/sort'; import { BaseModel } from '@models/base.model'; -import { TableSettingsKey, UIConfig } from '@models/config-ui.model'; +import { Sorting, TableSettingsKey, UIConfig } from '@models/config-ui.model'; import { JHash } from '@models/hash.model'; import { ContextMenuService } from '@services/context-menu/base/context-menu.service'; @@ -386,10 +386,13 @@ export class HTTableComponent implements OnInit, AfterViewInit, OnDestroy { * @param {any} column - The column that was clicked for sorting. * @returns {void} */ - onColumnHeaderClick(column: any): void { - const sorting = { - ...column, - direction: this.dataSource.sort['_direction'] + onColumnHeaderClick(column: HTTableColumn): void { + const sorting: Sorting = { + id: column.id, + dataKey: column.dataKey, + isSortable: column.isSortable, + direction: this.dataSource.sort['_direction'], + ...(column.parent ? { parent: column.parent } : {}) }; this.dataSource.sortingColumn = sorting; if (!this.isDetailPage) { diff --git a/src/app/core/_models/config-ui.model.ts b/src/app/core/_models/config-ui.model.ts index ca6320a0..0640d58d 100644 --- a/src/app/core/_models/config-ui.model.ts +++ b/src/app/core/_models/config-ui.model.ts @@ -92,7 +92,8 @@ export interface Sorting { id: number; dataKey: string; isSortable: boolean; - direction: 'asc' | 'desc'; + direction: 'asc' | 'desc' | ''; + parent?: string; } const _uiConfigDefault = { diff --git a/src/app/core/_models/config-ui.schema.spec.ts b/src/app/core/_models/config-ui.schema.spec.ts index d910811e..f452f23e 100644 --- a/src/app/core/_models/config-ui.schema.spec.ts +++ b/src/app/core/_models/config-ui.schema.spec.ts @@ -1,5 +1,5 @@ import { uiConfigDefault } from '@models/config-ui.model'; -import { uiConfigSchema, uisSettingsSchema } from '@models/config-ui.schema'; +import { sortingSchema, uiConfigSchema, uisSettingsSchema } from '@models/config-ui.schema'; describe('uiConfigSchema', () => { // Use a deep copy to avoid interference from other tests that mutate the @@ -144,6 +144,28 @@ describe('uiConfigSchema', () => { } }); + it('should preserve the parent field in sorting order (relationship sort)', () => { + const config = { + ...defaults, + tableSettings: { + tasksTable: { + columns: [1, 2], + page: 25, + search: '', + order: { id: 2, dataKey: 'taskName', isSortable: true, direction: 'asc', parent: 'task' } + } + } + }; + + const result = uiConfigSchema.safeParse(config); + expect(result.success).toBeTrue(); + if (result.success) { + const order = result.data.tableSettings['tasksTable']['order']; + expect(order).toBeDefined(); + expect(order['parent']).toBe('task'); + } + }); + it('should coerce string numbers to actual numbers in columns', () => { const config = { ...defaults, @@ -326,3 +348,87 @@ describe('uisSettingsSchema', () => { } }); }); + +/** + * Replicates the sort parameter construction from + * RequestParamBuilder.addSorting (builder-implementation.service.ts). + */ +function buildSortParam(col: { dataKey: string; direction: string; parent?: string }): string { + const direction = col.direction === 'asc' ? '' : '-'; + const parent = col.parent ? `${col.parent}.` : ''; + return `${direction}${parent}${col.dataKey}`; +} + +describe('sortingSchema – sort parameter round-trip', () => { + /** + * Every sortable column across all tables. Columns with a `parent` field + * produce a relationship sort like "task.taskName"; the rest produce a + * simple sort like "priority". + */ + const sortableColumns: { table: string; dataKey: string; id: number; parent?: string }[] = [ + // tasks-table (the only table with parent-based sort columns) + { table: 'tasks', dataKey: 'taskWrapperId', id: 0 }, + { table: 'tasks', dataKey: 'taskType', id: 1 }, + { table: 'tasks', dataKey: 'taskName', id: 2, parent: 'task' }, + { table: 'tasks', dataKey: 'hashlistId', id: 5, parent: 'hashlist' }, + { table: 'tasks', dataKey: 'cracked', id: 7 }, + { table: 'tasks', dataKey: 'groupName', id: 9, parent: 'accessGroup' }, + { table: 'tasks', dataKey: 'isSmall', id: 10, parent: 'task' }, + { table: 'tasks', dataKey: 'isCpuTask', id: 11, parent: 'task' }, + { table: 'tasks', dataKey: 'priority', id: 12 }, + { table: 'tasks', dataKey: 'maxAgents', id: 13 }, + // Representative columns from other tables (no parent) + { table: 'agents', dataKey: 'id', id: 0 }, + { table: 'agents', dataKey: 'agentName', id: 1 }, + { table: 'users', dataKey: 'name', id: 1 }, + { table: 'hashlists', dataKey: 'id', id: 0 }, + { table: 'files', dataKey: 'filename', id: 1 }, + { table: 'cracks', dataKey: 'timeCracked', id: 0 }, + ]; + + for (const direction of ['asc', 'desc'] as const) { + for (const col of sortableColumns) { + const label = col.parent ? `${col.parent}.${col.dataKey}` : col.dataKey; + + it(`[${col.table}] sort=${direction === 'desc' ? '-' : ''}${label} should survive schema round-trip`, () => { + // 1. Build the sorting object as onColumnHeaderClick would + const initial = { + id: col.id, + dataKey: col.dataKey, + isSortable: true as const, + direction, + ...(col.parent ? { parent: col.parent } : {}) + }; + + // 2. Round-trip through sortingSchema (simulates localStorage save + restore) + const result = sortingSchema.safeParse(initial); + expect(result.success).toBeTrue(); + + // 3. The sort parameter sent to the API must be identical + const expected = buildSortParam(initial); + const actual = buildSortParam(result.data as typeof initial); + expect(actual).toBe(expected); + }); + } + } + + it('should strip unknown fields on the sorting object', () => { + const withExtra = { + id: 1, + dataKey: 'taskName', + isSortable: true, + direction: 'asc' as const, + parent: 'task', + render: 'should not survive', + routerLink: 'should not survive' + }; + + const result = sortingSchema.safeParse(withExtra); + expect(result.success).toBeTrue(); + if (result.success) { + expect(result.data['render']).toBeUndefined(); + expect(result.data['routerLink']).toBeUndefined(); + expect(result.data.parent).toBe('task'); + } + }); +}); diff --git a/src/app/core/_models/config-ui.schema.ts b/src/app/core/_models/config-ui.schema.ts index c1ee49e7..71e86854 100644 --- a/src/app/core/_models/config-ui.schema.ts +++ b/src/app/core/_models/config-ui.schema.ts @@ -82,7 +82,8 @@ export const sortingSchema = z.object({ id: z.coerce.number(), dataKey: z.string(), isSortable: z.boolean(), - direction: z.enum(['asc', 'desc']) + direction: z.enum(['asc', 'desc', '']), + parent: z.string().optional() }); /** diff --git a/src/app/shared/utils/config.ts b/src/app/shared/utils/config.ts index 9f50bfb2..6ebe27ef 100644 --- a/src/app/shared/utils/config.ts +++ b/src/app/shared/utils/config.ts @@ -1,17 +1,10 @@ -import { TableSettingsKey, UIConfig, UIConfigKeys, uiConfigDefault } from '@models/config-ui.model'; +import { Sorting, TableSettingsKey, UIConfig, UIConfigKeys, uiConfigDefault } from '@models/config-ui.model'; import { uiConfigSchema } from '@models/config-ui.schema'; import { LocalStorageService } from '@services/storage/local-storage.service'; import { ThemeService } from '@src/app/core/_services/shared/theme.service'; -export interface TableOrder { - id: number; - dataKey: string; - isSortable: boolean; - direction: 'asc' | 'desc'; -} - /** * Utility class for managing user interface settings and configurations. */ @@ -46,7 +39,7 @@ export class UISettingsUtilityClass { * @param {number} [settings.start] - The start index to set. * @param {number[]} [settings.columns] - An array of column numbers to set. * @param {number[]} [settings.search] - An array of column numbers to set. - * @param {TableOrder[]} [settings.order] - An array defining the order of columns. + * @param {Sorting[]} [settings.order] - An array defining the order of columns. */ updateTableSettings( key: TableSettingsKey, @@ -57,7 +50,7 @@ export class UISettingsUtilityClass { before?: number; totalItems?: number; columns?: number[]; - order?: TableOrder[]; + order?: Sorting | Sorting[]; search?: string; } ): void {