diff --git a/public/i18n/en_US.json b/public/i18n/en_US.json index 995c52ca..3937a75d 100644 --- a/public/i18n/en_US.json +++ b/public/i18n/en_US.json @@ -875,7 +875,6 @@ "INVALID_XML_ZIP_EXTENSION_ALERT": "Sorry!<\/strong> SEED doesn't currently support that file format. Only .xml<\/strong> and .zip<\/strong> files are supported.", "IRREVERSIBLE_OPERATION_WARNING": "This operation is irreversible.", "ITEMS_WILL_NOT_CHANGE": "these will not change", - "I agree with the": "I agree with the", "If Enabled": "If Enabled", "If you are unable to scan the QR code, select a different method below.": "If you are unable to scan the QR code, select a different method below.", "If you wish to generate stub Audit Template reports from SEED data, select which Audit Template Report Type SEED should generate.": "If you wish to generate stub Audit Template reports from SEED data, select which Audit Template Report Type SEED should generate.", @@ -1184,6 +1183,7 @@ "Options": "Options", "Or Create New (With User as Head):": "Or Create New (With User as Head):", "Or select a field to add to the table. Selecting a field adds it to the table above so it can be shared.": "Or select a field to add to the table. Selecting a field adds it to the table above so it can be shared.", + "Order": "Order", "Organization": "Organization", "Organization \"{{orgName}}\" created": "Organization \"{{orgName}}\" created", "Organization \"{{orgName}}\" deleted": "Organization \"{{orgName}}\" deleted", @@ -1256,6 +1256,7 @@ "Percentage (%)": "Percentage (%)", "Performance Data": "Performance Data", "Permanently delete extra_data columns and all associated data": "Permanently delete extra_data columns and all associated data", + "Pin column to the left": "Pin column to the left", "Please enter your new password twice so we can verify you typed it in correctly.": "Please enter your new password twice so we can verify you typed it in correctly.", "Please go to the following page and choose a new password:": "Please go to the following page and choose a new password:", "Please go to the following page and setup your account:": "Please go to the following page and setup your account:", @@ -1844,6 +1845,7 @@ "Unknown": "Unknown", "Unmerge Last": "Unmerge Last", "Unmerge complete.": "Unmerge complete.", + "Unpin column": "Unpin column", "Unsatisfactory": "Unsatisfactory", "Unsuccessful Exports": "Unsuccessful Exports", "Until last date of": "Until last date of", diff --git a/public/i18n/es.json b/public/i18n/es.json index 56bbe23e..0c5beca8 100644 --- a/public/i18n/es.json +++ b/public/i18n/es.json @@ -1183,6 +1183,7 @@ "Options": "Opciones", "Or Create New (With User as Head):": "O Crear Nuevo (Con Usuario como Cabecera):", "Or select a field to add to the table. Selecting a field adds it to the table above so it can be shared.": "O bien, seleccione un campo para agregarlo a la tabla. Al seleccionar un campo, este se agregará a la tabla superior para que pueda compartirse.", + "Order": "Orden", "Organization": "Organización", "Organization \"{{orgName}}\" created": "Organización \"{{orgName}}\" creado", "Organization \"{{orgName}}\" deleted": "Organización \"{{orgName}}\" eliminado", @@ -1255,6 +1256,7 @@ "Percentage (%)": "Porcentaje (%)", "Performance Data": "Datos de rendimiento", "Permanently delete extra_data columns and all associated data": "Eliminar permanentemente las columnas extra_data y todos los datos asociados", + "Pin column to the left": "Columna de alfileres a la izquierda", "Please enter your new password twice so we can verify you typed it in correctly.": "Por favor, introduzca su nueva contraseña dos veces para que podamos verificar que la ha escrito correctamente.", "Please go to the following page and choose a new password:": "Vaya a la página siguiente y elija una nueva contraseña:", "Please go to the following page and setup your account:": "Vaya a la siguiente página y cree su cuenta:", @@ -1843,6 +1845,7 @@ "Unknown": "Desconocido", "Unmerge Last": "Fusión final", "Unmerge complete.": "Descombinación completada.", + "Unpin column": "Desanclar columna", "Unsatisfactory": "Insatisfactorio", "Unsuccessful Exports": "Exportaciones fallidas", "Until last date of": "Hasta la última fecha de", diff --git a/public/i18n/fr_CA.json b/public/i18n/fr_CA.json index 247a2e98..10edcdf8 100644 --- a/public/i18n/fr_CA.json +++ b/public/i18n/fr_CA.json @@ -1183,6 +1183,7 @@ "Options": "Options", "Or Create New (With User as Head):": "Ou Créer Nouveau (avec l'utilisateur en tant que chef):", "Or select a field to add to the table. Selecting a field adds it to the table above so it can be shared.": "Vous pouvez également sélectionner un champ à ajouter au tableau. La sélection d'un champ l'ajoute au tableau ci-dessus afin qu'il puisse être partagé.", + "Order": "Commande", "Organization": "Organisation", "Organization \"{{orgName}}\" created": "Organisation \"{{orgName}}\" créé", "Organization \"{{orgName}}\" deleted": "Organisation \"{{orgName}}\" supprimé", @@ -1255,6 +1256,7 @@ "Percentage (%)": "Pourcentage (%)", "Performance Data": "Données de performance", "Permanently delete extra_data columns and all associated data": "Supprimer définitivement les colonnes extra_data et toutes les données associées", + "Pin column to the left": "Colonne d'épingles à gauche", "Please enter your new password twice so we can verify you typed it in correctly.": "Entrez votre nouveau mot de passe deux fois afin que nous puissions vérifier que vous l'avez tapé correctement.", "Please go to the following page and choose a new password:": "Passez à la page suivante et choisissez un nouveau mot de passe:", "Please go to the following page and setup your account:": "Passez à la page suivante et configurez votre compte:", @@ -1843,6 +1845,7 @@ "Unknown": "Inconnue", "Unmerge Last": "Défaire la fusion Dernière", "Unmerge complete.": "Fusion terminée.", + "Unpin column": "Colonne décrochée", "Unsatisfactory": "Insatisfaisant", "Unsuccessful Exports": "Exportations infructueuses", "Until last date of": "Jusqu'à la dernière date de", diff --git a/src/@seed/components/column-profiles/column-profiles.component.html b/src/@seed/components/column-profiles/column-profiles.component.html index ff46f721..57511635 100644 --- a/src/@seed/components/column-profiles/column-profiles.component.html +++ b/src/@seed/components/column-profiles/column-profiles.component.html @@ -1,6 +1,6 @@
-
-
+
+
Column List Profile { + let component: ColumnProfilesComponent + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: ColumnService, useValue: {} }, + { provide: InventoryService, useValue: {} }, + { provide: OrganizationService, useValue: {} }, + { provide: UserService, useValue: {} }, + { provide: MatDialog, useValue: {} }, + { provide: ConfigService, useValue: { gridTheme$: of(null) } }, + { provide: TranslocoService, useValue: { translate: (key: string) => key } }, + ], + }) + component = TestBed.runInInjectionContext(() => new ColumnProfilesComponent()) + component.profileType = 'list' + component.columns = [ + { id: 1, column_name: 'alpha', display_name: 'Alpha' }, + { id: 2, column_name: 'beta', display_name: 'Beta' }, + { id: 3, column_name: 'gamma', display_name: 'Gamma' }, + ] as Column[] + }) + + afterEach(() => { + component.ngOnDestroy() + }) + + it('loads saved order and puts pinned columns first', () => { + component.setRowData(new Set([1, 2, 3]), [ + { id: 3, order: 1, pinned: false }, + { id: 2, order: 2, pinned: true }, + { id: 1, order: 3, pinned: false }, + ]) + + expect(component.getSelectedProfileColumns().map((row) => [row.id, row.order, row.pinned])).toEqual([ + [2, 1, true], + [3, 2, false], + [1, 3, false], + ]) + }) + + it('preserves order and pins when adding or removing a column', () => { + component.setRowData(new Set([1, 3]), [ + { id: 3, order: 1, pinned: true }, + { id: 1, order: 2, pinned: false }, + ]) + component.setRowData(new Set([1, 2, 3])) + expect(component.getSelectedProfileColumns().map((row) => row.id)).toEqual([3, 1, 2]) + expect(component.rowData[0].pinned).toBeTrue() + + component.setRowData(new Set([1, 2])) + component.setRowData(new Set([1, 2, 3])) + expect(component.getSelectedProfileColumns().map((row) => row.id)).toEqual([1, 2, 3]) + expect(component.rowData.find((row) => row.id === 3).pinned).toBeFalse() + }) + + it('saves drag order rather than stale order values', () => { + component.setRowData(new Set([1, 2, 3])) + const [first, second, third] = component.rowData + component.gridApi = { + forEachNodeAfterFilterAndSort: (callback) => { + for (const [index, data] of [third, first, second].entries()) callback({ data } as IRowNode, index) + }, + getSelectedRows: () => [first, second, third], + } as GridApi + spyOn(component, 'setSelectedRows') + + component.onRowDragEnd() + + expect(component.getSelectedProfileColumns().map((row) => [row.id, row.order])).toEqual([[3, 1], [1, 2], [2, 3]]) + }) + + it('supports numeric keyboard ordering within the selected columns', () => { + component.setRowData(new Set([1, 2, 3])) + component.setColumnOrder(component.rowData[2], 1) + expect(component.getSelectedProfileColumns().map((row) => row.id)).toEqual([3, 1, 2]) + }) + + it('allows derived columns to be pinned and selects them automatically', () => { + component.setRowData(new Set()) + const row = component.rowData[0] + row.derived_column = 1 + const setSelected = jasmine.createSpy('setSelected') + component.gridApi = { + getSelectedRows: () => [], + } as unknown as GridApi + spyOn(component, 'setRowData') + + component.togglePinned(row, setSelected) + + expect(setSelected).toHaveBeenCalledOnceWith(true) + expect(row.pinned).toBeTrue() + expect(component.getSelectedProfileColumns().map((column) => column.id)).toContain(row.id) + expect(component.setRowData).toHaveBeenCalledWith(new Set([row.id]), component.rowData) + }) + + it('pins the focused pin cell with Enter or Space and suppresses grid shortcuts', () => { + component.setRowData(new Set([1])) + component.gridApi = { + getSelectedRows: () => [], + } as unknown as GridApi + spyOn(component, 'setRowData') + + for (const key of ['Enter', ' ']) { + const row = { ...component.rowData[0] } + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key }) + const setSelected = jasmine.createSpy('setSelected') + spyOn(event, 'stopPropagation') + + const suppressed = component.onPinCellKeyDown(event, row, setSelected) + + expect(suppressed).toBeTrue() + expect(event.defaultPrevented).toBeTrue() + expect(event.stopPropagation).toHaveBeenCalled() + expect(setSelected).toHaveBeenCalledOnceWith(true) + expect(row.pinned).toBeTrue() + } + }) + + it('does not suppress other pin-cell keyboard events', () => { + const event = new KeyboardEvent('keydown', { key: 'ArrowDown' }) + const setSelected = jasmine.createSpy('setSelected') + spyOn(component, 'togglePinned') + + const suppressed = component.onPinCellKeyDown(event, {} as ProfileColumn, setSelected) + + expect(suppressed).toBeFalse() + expect(event.defaultPrevented).toBeFalse() + expect(setSelected).not.toHaveBeenCalled() + expect(component.togglePinned).not.toHaveBeenCalled() + }) + + it('captures deselection and clears pins before the delayed refresh', () => { + component.setRowData(new Set([1]), [{ id: 1, order: 1, pinned: true }]) + const row = component.rowData[0] + component.onRowSelected({ data: row, source: 'checkboxSelected', node: { isSelected: () => false } } as RowSelectedEvent) + expect(component.getSelectedProfileColumns()).toEqual([]) + expect(row.pinned).toBeFalse() + }) + + it('retains detail profile order without enabling list-only pins', () => { + component.profileType = 'detail' + component.setRowData(new Set([1, 2]), [ + { id: 2, order: 1, pinned: true }, + { id: 1, order: 2, pinned: false }, + ]) + expect(component.getSelectedProfileColumns().map((row) => [row.id, row.pinned])).toEqual([[2, false], [1, false]]) + }) +}) diff --git a/src/@seed/components/column-profiles/column-profiles.component.ts b/src/@seed/components/column-profiles/column-profiles.component.ts index 2f57bb11..9ce6d551 100644 --- a/src/@seed/components/column-profiles/column-profiles.component.ts +++ b/src/@seed/components/column-profiles/column-profiles.component.ts @@ -3,8 +3,9 @@ import type { OnDestroy, OnInit } from '@angular/core' import { ChangeDetectionStrategy, Component, inject, Input } from '@angular/core' import { MatDialog } from '@angular/material/dialog' import type { MatSelectChange } from '@angular/material/select' +import { TranslocoService } from '@jsverse/transloco' import { AgGridAngular } from 'ag-grid-angular' -import type { ColDef, GridApi, GridOptions, GridReadyEvent, RowSelectedEvent } from 'ag-grid-community' +import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams, RowSelectedEvent } from 'ag-grid-community' import { combineLatest, filter, Subject, switchMap, takeUntil, tap } from 'rxjs' import type { Column, CurrentUser } from '@seed/api' import { ColumnService, InventoryService, OrganizationService, UserService } from '@seed/api' @@ -15,7 +16,8 @@ import { naturalSort } from '@seed/utils' import { ModalComponent } from 'app/modules/column-list-profile/modal/modal.component' import type { InventoryDisplayType, InventoryType, Profile, ProfileColumn, ProfileModalMode } from 'app/modules/inventory/inventory.types' -type CellRendererParams = { value: string; data: { derived_column: number; is_extra_data: boolean } } +type CellRendererParams = { value: string; data: ProfileColumn & { is_extra_data: boolean } } +type ProfileColumnSettings = Pick & Partial> @Component({ selector: 'seed-column-profiles', templateUrl: './column-profiles.component.html', @@ -30,6 +32,7 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { private _dialog = inject(MatDialog) private _inventoryService = inject(InventoryService) private _organizationService = inject(OrganizationService) + private _translocoService = inject(TranslocoService) private _userService = inject(UserService) private readonly _unsubscribeAll$ = new Subject() columnDefs: ColDef[] = [] @@ -46,8 +49,12 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { updateOrgUserSettings$ = new Subject() rowData: ProfileColumn[] = [] private _rowSelectedTimer: ReturnType + private _selectionTimer: ReturnType gridOptions: GridOptions = { + rowDragManaged: true, + selectionColumnDef: { width: 44, maxWidth: 44, resizable: false }, + getRowId: (params) => String((params.data as ProfileColumn).id), rowSelection: { mode: 'multiRow', checkboxes: true, @@ -56,10 +63,13 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { onRowSelected: (event) => { this.onRowSelected(event) }, + onRowDragEnd: () => { + this.onRowDragEnd() + }, } defaultColDef = { - sortable: true, + sortable: false, filter: true, resizable: true, suppressMovable: true, @@ -69,6 +79,7 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { ngOnInit(): void { this.profileLocation = this.profileType === 'list' ? 'List View Profile' : 'Detail View Profile' this.displayType = this.type === 'taxlots' ? 'Tax Lot' : 'Property' + this.initStreams() this.initPage() } @@ -78,7 +89,6 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { takeUntil(this._unsubscribeAll$), tap(() => { this.setGrid() - this.initStreams() }), ) .subscribe() @@ -90,8 +100,12 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { filter(Boolean), takeUntil(this._unsubscribeAll$), switchMap((data) => this._inventoryService.updateColumnListProfile(this.orgId, this.currentProfile.id, data)), - tap(() => { - this.initPage() + tap((profile) => { + this.profiles = this.profiles.map((existing) => (existing.id === profile.id ? profile : existing)) + if (this.currentProfile?.id === profile.id) { + this.currentProfile = profile + this.setGrid() + } }), ) .subscribe() @@ -139,19 +153,49 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { // use current profile if it exists, otherwise use all canonical columns const selectedCols = this.currentProfile?.columns ?? this.columns.filter((c) => !c.is_extra_data && !c.derived_column) const selectedColIds = new Set(selectedCols.map((c: { id: number }) => c.id)) - this.setRowData(selectedColIds) + this.setRowData(selectedColIds, selectedCols) } setColumnDefs() { this.columnDefs = [ + { + field: 'pinned', + headerName: '', + hide: this.profileType !== 'list', + width: 44, + maxWidth: 44, + sortable: false, + filter: false, + resizable: false, + cellRenderer: this.profileType === 'list' ? this.pinRenderer : undefined, + suppressKeyboardEvent: (params) => { + const row = params.node.data as ProfileColumn | undefined + if (!row) return false + return this.onPinCellKeyDown(params.event, row, (selected) => { + params.node.setSelected(selected) + }) + }, + }, + { field: 'drag', headerName: '', rowDrag: true, width: 44, maxWidth: 44, sortable: false, filter: false, resizable: false }, { field: 'column_name', hide: true }, { field: 'display_name', headerName: 'Column Name', cellRenderer: this.columnRenderer }, { field: 'derived_column', hide: true }, { field: 'is_extra_data', hide: true }, { field: 'id', hide: true }, { field: 'name', hide: true }, - { field: 'order', hide: true }, - { field: 'pinned', hide: true }, + { + field: 'order', + headerName: this._translocoService.translate('Order'), + width: 96, + maxWidth: 120, + filter: false, + editable: (params) => (params.data as ProfileColumn).selected === true, + cellEditor: 'agNumberCellEditor', + cellEditorParams: () => ({ min: 1, max: this.rowData.filter((row) => row.selected).length, precision: 0 }), + onCellValueChanged: (event) => { + this.setColumnOrder(event.data as ProfileColumn, Number(event.newValue)) + }, + }, { field: 'table_name', hide: true }, ] } @@ -177,26 +221,67 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { return container } - // pinRenderer = () => { - // return ` - // - // push_pin - // - // ` - // } + pinRenderer = (params: ICellRendererParams) => { + const row = params.data + if (!row) return '' + + const button = document.createElement('button') + button.type = 'button' + button.className = 'flex h-8 w-full items-center justify-center' + button.title = this._translocoService.translate(row.pinned ? 'Unpin column' : 'Pin column to the left') + button.setAttribute('aria-label', button.title) + button.setAttribute('aria-pressed', String(row.pinned)) + + const icon = document.createElement('span') + icon.className = `material-icons ${row.pinned ? 'text-primary-700' : 'text-secondary opacity-50'}` + icon.textContent = 'push_pin' + icon.setAttribute('aria-hidden', 'true') + button.append(icon) + + button.addEventListener('click', (event) => { + event.stopPropagation() + const currentRow = params.node.data + if (!currentRow) return + this.togglePinned(currentRow, (selected) => { + params.node.setSelected(selected) + }) + }) + + return button + } + + onPinCellKeyDown(event: KeyboardEvent, row: ProfileColumn, setSelected: (selected: boolean) => void): boolean { + if (event.key !== 'Enter' && event.key !== ' ') return false + + event.preventDefault() + event.stopPropagation() + this.togglePinned(row, setSelected) + return true + } + + togglePinned(row: ProfileColumn, setSelected: (selected: boolean) => void) { + row.pinned = !row.pinned + if (row.pinned) { + row.selected = true + setSelected(true) + } + + const selectedIds = new Set(this.gridApi.getSelectedRows().map((selectedRow: ProfileColumn) => selectedRow.id)) + if (row.pinned) selectedIds.add(row.id) + this.setRowData(selectedIds, this.rowData) + } /** * Place selected rows (profile columns) at the top of the grid */ - setRowData(selectedColIds: Set) { + setRowData(selectedColIds: Set, profileColumns: ProfileColumnSettings[] = this.rowData) { let [selectedRows, unselectedRows]: [ProfileColumn[], ProfileColumn[]] = [[], []] - let idx = 0 + const profileColumnById = new Map(profileColumns.map((column, index) => [column.id, { ...column, order: column.order ?? index + 1 }])) + for (const col of this.columns) { const { column_name, derived_column, display_name, id, is_extra_data, name, table_name } = col + const profileColumn = profileColumnById.get(id) const data = { column_name, display_name, @@ -215,9 +300,8 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { } if (selectedColIds.has(col.id)) { - idx += 1 - data.order = idx - data.pinned = false + data.order = profileColumn?.order + data.pinned = this.profileType === 'list' && profileColumn?.pinned === true data.selected = true selectedRows.push(data) } else { @@ -225,12 +309,20 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { } } - selectedRows = selectedRows.sort((a, b) => naturalSort(a.display_name, b.display_name)) + selectedRows = selectedRows.sort((a, b) => { + if (a.pinned !== b.pinned) return a.pinned ? -1 : 1 + return (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER) || naturalSort(a.display_name, b.display_name) + }) unselectedRows = unselectedRows.sort((a, b) => naturalSort(a.display_name, b.display_name)) - this.rowData = [...selectedRows, ...unselectedRows] + let order = 0 + this.rowData = [...selectedRows, ...unselectedRows].map((row) => ({ + ...row, + order: row.selected ? ++order : undefined, + })) if (this.gridApi) { - setTimeout(() => { + clearTimeout(this._selectionTimer) + this._selectionTimer = setTimeout(() => { this.setSelectedRows() }, 0) } @@ -246,9 +338,7 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { setSelectedRows() { this.gridApi.forEachNode((node) => { - // setSelected takes 2 args: selected & clearOtherSelections. Without 2nd arg, unselected appears as [-] not [ ] - const selectionArgs: [boolean, boolean] = (node.data as ProfileColumn).selected ? [true, null] : [false, true] - node.setSelected(...selectionArgs) + node.setSelected((node.data as ProfileColumn).selected === true) }) } @@ -256,20 +346,48 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { // Ignore programmatic selection and header checkbox intermediate events if (event.source === 'api') return + const row = event.data as ProfileColumn + row.selected = event.node.isSelected() === true + if (!row.selected) row.pinned = false + // Defer to let AG Grid finish processing all row selections (e.g., header checkbox "select all") if (this._rowSelectedTimer) clearTimeout(this._rowSelectedTimer) this._rowSelectedTimer = setTimeout(() => { const selectedRows = new Set(this.gridApi.getSelectedRows().map((r: ProfileColumn) => r.id)) - this.setRowData(selectedRows) + this.setRowData(selectedRows, this.rowData) }, 50) } + onRowDragEnd() { + const visibleRows: ProfileColumn[] = [] + this.gridApi.forEachNodeAfterFilterAndSort((node) => { + if (node.data) visibleRows.push(node.data as ProfileColumn) + }) + + const visibleIds = new Set(visibleRows.map((row) => row.id)) + let nextVisibleRow = 0 + const orderedRows = this.rowData.map((row) => (visibleIds.has(row.id) ? visibleRows[nextVisibleRow++] : row)) + let order = 0 + const profileColumns = orderedRows.map((row) => (row.selected ? { ...row, order: ++order } : row)) + const selectedIds = new Set(this.gridApi.getSelectedRows().map((row: ProfileColumn) => row.id)) + this.setRowData(selectedIds, profileColumns) + } + + setColumnOrder(column: ProfileColumn, requestedOrder: number) { + const selectedRows = this.rowData.filter((row) => row.selected && row.id !== column.id) + const order = Number.isFinite(requestedOrder) ? Math.trunc(requestedOrder) : 1 + selectedRows.splice(Math.max(0, Math.min(order - 1, selectedRows.length)), 0, column) + const profileColumns = selectedRows.map((row, index) => ({ ...row, order: index + 1 })) + this.setRowData(new Set(selectedRows.map((row) => row.id)), profileColumns) + } + selectProfile(event: MatSelectChange) { + clearTimeout(this._rowSelectedTimer) const profileId: number = event.value as number const profile = this.profiles.find((p) => p.id === profileId) this.currentUser.settings.profile[this.profileType][this.type] = profileId this.currentProfile = profile - this.setRowData(new Set(profile.columns.map((c) => c.id))) + this.setRowData(new Set(profile.columns.map((c) => c.id)), profile.columns) this.updateOrgUserSettings$.next() } @@ -333,16 +451,25 @@ export class ColumnProfilesComponent implements OnDestroy, OnInit { } onSave = () => { + this.gridApi?.stopEditing() if (!this.currentProfile) { - this.openProfileModal('create', this.gridApi.getSelectedRows() as ProfileColumn[]) + this.openProfileModal('create', this.getSelectedProfileColumns()) return } - const data = { ...this.currentProfile, columns: this.gridApi.getSelectedRows() } + const data = { ...this.currentProfile, columns: this.getSelectedProfileColumns() } this.updateCLP$.next(data) } + getSelectedProfileColumns(): ProfileColumn[] { + return this.rowData + .filter((row) => row.selected) + .map((row, index) => ({ ...row, order: index + 1, pinned: this.profileType === 'list' && row.pinned })) + } + ngOnDestroy(): void { + clearTimeout(this._rowSelectedTimer) + clearTimeout(this._selectionTimer) this._unsubscribeAll$.next() this._unsubscribeAll$.complete() } diff --git a/src/app/modules/inventory-list/list/inventory.component.spec.ts b/src/app/modules/inventory-list/list/inventory.component.spec.ts new file mode 100644 index 00000000..1979002b --- /dev/null +++ b/src/app/modules/inventory-list/list/inventory.component.spec.ts @@ -0,0 +1,33 @@ +import type { ColDef } from 'ag-grid-community' +import type { Profile } from 'app/modules/inventory' +import { applyColumnProfile } from './inventory.component' + +describe('InventoryComponent profile columns', () => { + it('applies saved order and pins using unique API field IDs', () => { + const profile: Profile = { + id: 1, + name: 'Test profile', + inventory_type: 'Property', + profile_location: 'List View Profile', + columns: [ + { id: 11, column_name: 'address_line_1', display_name: 'Address', name: 'address_line_1', table_name: 'PropertyState', order: 2, pinned: false }, + { id: 22, column_name: 'address_line_1', display_name: 'Address', name: 'address_line_1', table_name: 'TaxLotState', order: 1, pinned: true }, + ], + } + const columnDefs: ColDef[] = [{ field: 'address_line_1_11' }, { field: 'address_line_1_22' }, { field: 'other' }] + + const result = applyColumnProfile(profile, columnDefs) + + expect(result.map((column) => [column.field, column.pinned])).toEqual([ + ['address_line_1_22', 'left'], + ['address_line_1_11', null], + ['other', undefined], + ]) + expect(columnDefs[0].field).toBe('address_line_1_11') + }) + + it('keeps the API definitions unchanged when no profile is selected', () => { + const columnDefs: ColDef[] = [{ field: 'address_line_1' }] + expect(applyColumnProfile(null, columnDefs)).toBe(columnDefs) + }) +}) diff --git a/src/app/modules/inventory-list/list/inventory.component.ts b/src/app/modules/inventory-list/list/inventory.component.ts index 96924553..66b77f7f 100644 --- a/src/app/modules/inventory-list/list/inventory.component.ts +++ b/src/app/modules/inventory-list/list/inventory.component.ts @@ -27,6 +27,22 @@ import { } from './grid' import type { LabelSelections } from './grid/filter-group/filter-group-selector.component' +export function applyColumnProfile(profile: Profile, columnDefs: ColDef[]): ColDef[] { + if (!profile?.columns) return columnDefs + + const profileColumns = new Map(profile.columns.map((column) => [`${column.column_name}_${column.id}`, column])) + return columnDefs + .map((column) => { + const profileColumn = profileColumns.get(column.field) + return profileColumn ? { ...column, pinned: profileColumn.pinned ? 'left' as const : null } : column + }) + .sort((first, second) => { + const firstOrder = profileColumns.get(first.field)?.order ?? Number.MAX_SAFE_INTEGER + const secondOrder = profileColumns.get(second.field)?.order ?? Number.MAX_SAFE_INTEGER + return firstOrder - secondOrder + }) +} + @Component({ selector: 'seed-inventory', templateUrl: './inventory.component.html', @@ -311,7 +327,7 @@ export class InventoryComponent implements OnDestroy, OnInit { tap(({ pagination, results, column_defs }: AgFilterResponse) => { this.pagination = pagination this.inventory = results - this.columnDefs = column_defs + this.columnDefs = this.applyProfileColumns(column_defs) this.rowData = results }), map(() => null), @@ -328,6 +344,10 @@ export class InventoryComponent implements OnDestroy, OnInit { if (settingsCycleId !== this.cycleId) this.cycleId = settingsCycleId } + applyProfileColumns(columnDefs: ColDef[]): ColDef[] { + return applyColumnProfile(this.profile, columnDefs) + } + /* * on initial page load, set any filters and sorts from the user settings */