From f434db3d3ef10348bcebec3f97c6c49dd2ed0040 Mon Sep 17 00:00:00 2001 From: John White <750350+johnhwhite@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:05:47 -0500 Subject: [PATCH 1/2] feat(components/data-grid): split out simple option --- .../data-grid/basic/example.component.html | 4 +- .../data-grid/filtered/example.component.html | 4 +- .../data-grid/paging/example.component.html | 4 +- libs/components/data-grid/README.md | 5 + libs/components/data-grid/documentation.json | 1 + libs/components/data-grid/src/index.ts | 1 + .../data-grid/data-grid-lite.component.html | 29 + .../data-grid-lite.component.spec.ts | 945 ++++++++++++++++++ .../data-grid/data-grid-lite.component.ts | 214 ++++ .../data-grid/data-grid.component.html | 24 +- .../data-grid/data-grid.component.spec.ts | 637 +----------- .../modules/data-grid/data-grid.component.ts | 704 +------------ .../modules/data-grid/data-grid.directive.ts | 885 ++++++++++++++++ .../lib/modules/data-grid/data-grid.module.ts | 13 +- .../data-grid-lite-test.component.html | 238 +++++ .../fixtures/data-grid-lite-test.component.ts | 194 ++++ .../modules/data-grid/data-grid-harness.ts | 2 +- .../data-manager-host-controller.directive.ts | 10 +- 18 files changed, 2586 insertions(+), 1328 deletions(-) create mode 100644 libs/components/data-grid/src/lib/modules/data-grid/data-grid-lite.component.html create mode 100644 libs/components/data-grid/src/lib/modules/data-grid/data-grid-lite.component.spec.ts create mode 100644 libs/components/data-grid/src/lib/modules/data-grid/data-grid-lite.component.ts create mode 100644 libs/components/data-grid/src/lib/modules/data-grid/data-grid.directive.ts create mode 100644 libs/components/data-grid/src/lib/modules/data-grid/fixtures/data-grid-lite-test.component.html create mode 100644 libs/components/data-grid/src/lib/modules/data-grid/fixtures/data-grid-lite-test.component.ts diff --git a/libs/components/code-examples/src/lib/modules/data-grid/basic/example.component.html b/libs/components/code-examples/src/lib/modules/data-grid/basic/example.component.html index a7ffb82ffb..f81061e85e 100644 --- a/libs/components/code-examples/src/lib/modules/data-grid/basic/example.component.html +++ b/libs/components/code-examples/src/lib/modules/data-grid/basic/example.component.html @@ -9,7 +9,7 @@ - {{ row.jobTitle?.name }} - + - - + diff --git a/libs/components/code-examples/src/lib/modules/data-grid/paging/example.component.html b/libs/components/code-examples/src/lib/modules/data-grid/paging/example.component.html index 10f69c7d8c..c3a3536775 100644 --- a/libs/components/code-examples/src/lib/modules/data-grid/paging/example.component.html +++ b/libs/components/code-examples/src/lib/modules/data-grid/paging/example.component.html @@ -1,4 +1,4 @@ - {{ value?.name ?? '' }} - + diff --git a/libs/components/data-grid/README.md b/libs/components/data-grid/README.md index 8a47d7d2eb..9d343eff4d 100644 --- a/libs/components/data-grid/README.md +++ b/libs/components/data-grid/README.md @@ -1,3 +1,8 @@ # data-grid This library was generated with [Nx](https://nx.dev). + +## In development + +- [ ] Refactor so `skyAgGridDataManagerAdapter` is not required in the template. +- [ ] diff --git a/libs/components/data-grid/documentation.json b/libs/components/data-grid/documentation.json index a12edb2958..fc561983a2 100644 --- a/libs/components/data-grid/documentation.json +++ b/libs/components/data-grid/documentation.json @@ -5,6 +5,7 @@ "development": { "docsIds": [ "SkyDataGridModule", + "SkyDataGridLiteComponent", "SkyDataGridComponent", "SkyDataGridColumnComponent", "SkyDataGridFilterOperator", diff --git a/libs/components/data-grid/src/index.ts b/libs/components/data-grid/src/index.ts index 10048390c4..a78d338431 100644 --- a/libs/components/data-grid/src/index.ts +++ b/libs/components/data-grid/src/index.ts @@ -1,4 +1,5 @@ export { SkyDataGridComponent } from './lib/modules/data-grid/data-grid.component'; +export { SkyDataGridLiteComponent } from './lib/modules/data-grid/data-grid-lite.component'; export { SkyDataGridColumnComponent } from './lib/modules/data-grid/data-grid-column.component'; export { SkyDataGridModule } from './lib/modules/data-grid/data-grid.module'; export { SkyDataGridFilterOperator } from './lib/modules/types/data-grid-filter-operator'; diff --git a/libs/components/data-grid/src/lib/modules/data-grid/data-grid-lite.component.html b/libs/components/data-grid/src/lib/modules/data-grid/data-grid-lite.component.html new file mode 100644 index 0000000000..d920dd7c83 --- /dev/null +++ b/libs/components/data-grid/src/lib/modules/data-grid/data-grid-lite.component.html @@ -0,0 +1,29 @@ + +@if (directive.gridOptions(); as gridOptions) { + + + + + @if (directive.pageCount(); as pageCount) { + + } +} diff --git a/libs/components/data-grid/src/lib/modules/data-grid/data-grid-lite.component.spec.ts b/libs/components/data-grid/src/lib/modules/data-grid/data-grid-lite.component.spec.ts new file mode 100644 index 0000000000..458d0738ed --- /dev/null +++ b/libs/components/data-grid/src/lib/modules/data-grid/data-grid-lite.component.spec.ts @@ -0,0 +1,945 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { provideLocationMocks } from '@angular/common/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideRouter } from '@angular/router'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { SkyAgGridWrapperHarness } from '@skyux/ag-grid/testing'; +import { SkyLogService } from '@skyux/core'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { SkyWaitHarness } from '@skyux/indicators/testing'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { SkyPagingHarness } from '@skyux/lists/testing'; + +import { getGridApi } from 'ag-grid-community'; + +import { SkyDataGridComponent } from './data-grid.component'; +import { DataGridLiteTestComponent } from './fixtures/data-grid-lite-test.component'; + +describe('SkyDataGridLiteComponent', () => { + let fixture: ComponentFixture; + let component: DataGridLiteTestComponent; + let loader: HarnessLoader; + let loggerSpy: jasmine.Spy; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideRouter([]), provideLocationMocks()], + }); + fixture = TestBed.createComponent(DataGridLiteTestComponent, {}); + loader = TestbedHarnessEnvironment.loader(fixture); + component = fixture.componentInstance; + const logger = TestBed.inject(SkyLogService); + loggerSpy = spyOn(logger, 'warn').and.returnValue(undefined); + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should destroy and recreate grid', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + expect(component).toBeTruthy(); + + fixture.componentRef.setInput('showAllGrids', false); + fixture.detectChanges(); + await fixture.whenStable(); + expect( + fixture.debugElement.queryAll(By.directive(SkyDataGridComponent)), + ).toEqual([]); + + fixture.componentRef.setInput('showAllGrids', true); + fixture.detectChanges(); + await fixture.whenStable(); + expect( + fixture.debugElement.queryAll(By.directive(SkyDataGridComponent)).length, + ).toEqual(4); + }); + + it('should destroy and recreate columns', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + expect(component).toBeTruthy(); + const grids = await loader.getAllHarnesses(SkyAgGridWrapperHarness); + expect( + await Promise.all(grids.map((grid) => grid.getDisplayedColumnIds())).then( + (cols) => cols.map((id) => id.length).reduce((a, b) => a + b, 0), + ), + ).toEqual(4 * 3 + 3); // 4 grids with 3 columns each, plus 3 extra headers for the multi-select and row delete grid + + fixture.componentRef.setInput('showCol3', false); + fixture.detectChanges(); + await fixture.whenStable(); + expect( + await Promise.all(grids.map((grid) => grid.getDisplayedColumnIds())).then( + (cols) => cols.map((id) => id.length).reduce((a, b) => a + b, 0), + ), + ).toEqual(4 * 2 + 4); // 4 grids with 2 columns each, plus 4 extra headers for multi-select, row delete, and date column + + fixture.componentRef.setInput('showCol3', true); + fixture.detectChanges(); + await fixture.whenStable(); + expect( + await Promise.all(grids.map((grid) => grid.getDisplayedColumnIds())).then( + (cols) => cols.map((id) => id.length).reduce((a, b) => a + b, 0), + ), + ).toEqual(4 * 3 + 3); // 4 grids with 3 columns each, plus 3 extra headers for the multi-select and row delete grid + + fixture.componentRef.setInput('showAllColumns', false); + fixture.detectChanges(); + await fixture.whenStable(); + expect( + fixture.nativeElement.querySelectorAll('sky-ag-grid-wrapper'), + ).toHaveSize(0); + }); + + it('should handle empty data', async () => { + component.dataForSimpleGrid = undefined; + fixture.detectChanges(); + expect(component).toBeTruthy(); + const waitHarness = + await TestbedHarnessEnvironment.loader(fixture).getHarness( + SkyWaitHarness, + ); + await expectAsync(waitHarness.isWaiting()).toBeResolvedTo(false); + expect( + Array.from( + fixture.nativeElement + .querySelector('.ag-viewport') + .querySelectorAll('[role="row"]'), + ), + ).toHaveSize(0); + }); + + it('should handle data changing from populated to undefined', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getDisplayedRowCount()).toBe(7); + + component.dataForSimpleGrid = undefined; + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(0); + }); + + it('should handle data changing from undefined to populated', async () => { + component.dataForSimpleGrid = undefined; + fixture.detectChanges(); + await fixture.whenStable(); + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getDisplayedRowCount()).toBe(0); + + component.dataForSimpleGrid = [ + { id: '1', column1: '1', column2: 'Apple', column3: true }, + { id: '2', column1: '01', column2: 'Banana', column3: false }, + ]; + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(2); + }); + + it('should update grid options when pageSize changes', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getGridOption('pagination')).toBeFalsy(); + + fixture.componentRef.setInput('pageSize', 2); + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); // Wait for grid to process option change + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); // Wait for grid to process option change + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); // Wait for grid to process option change + expect(api?.getGridOption('pagination')).toBeTruthy(); + expect(api?.getGridOption('paginationPageSize')).toBe(2); + }); + + it('should update grid options when height changes', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getGridOption('domLayout')).toBe('autoHeight'); + + fixture.componentRef.setInput('height', 200); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getGridOption('domLayout')).toBe('normal'); + + fixture.componentRef.setInput('height', 0); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getGridOption('domLayout')).toBe('autoHeight'); + }); + + it('should update grid options when multiselect changes', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getGridOption('rowSelection')).toEqual( + jasmine.objectContaining({ + mode: 'singleRow', + enableClickSelection: false, + enableSelectionWithoutKeys: true, + checkboxes: false, + }), + ); + + fixture.componentRef.setInput('multiselect', true); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getGridOption('rowSelection')).toEqual({ + mode: 'multiRow', + checkboxes: true, + headerCheckbox: true, + checkboxLocation: 'selectionColumn', + }); + }); + + it('should select all rows', async () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + const waitHarness = + await TestbedHarnessEnvironment.loader(fixture).getHarness( + SkyWaitHarness, + ); + await expectAsync(waitHarness.isWaiting()).toBeResolvedTo(false); + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="multiselect-grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getSelectedNodes()).toHaveSize(0); + api?.selectAll(); + expect(api?.getSelectedNodes()).toHaveSize(7); + }); + + it('should select some rows', async () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + fixture.detectChanges(); + await fixture.whenStable(); + const waitHarnesses = + await TestbedHarnessEnvironment.loader(fixture).getAllHarnesses( + SkyWaitHarness, + ); + await expectAsync( + Promise.all(waitHarnesses.map((waitHarness) => waitHarness.isWaiting())), + ).toBeResolvedTo([false, false, false, false]); + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="multiselect-grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getSelectedNodes()).toHaveSize(0); + component.selectedRowIds.set(['2', '4', '6']); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getSelectedNodes()).toHaveSize(3); + }); + + it('should update selectedRowIds when data changes to remove IDs no longer in data', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + // Select some rows + component.selectedRowIds.set(['101', '102', '103', '104', '105']); + fixture.detectChanges(); + await fixture.whenStable(); + // None of the IDs were valid. + expect(component.selectedRowIds()).toEqual([]); + + component.selectedRowIds.set(['1', '2', '3', '4', '5']); + fixture.detectChanges(); + await fixture.whenStable(); + expect(component.selectedRowIds()).toEqual(['1', '2', '3', '4', '5']); + + // Remove some items from the data (remove 2, 4) + component.dataForSimpleGridWithMultiselect = [ + { id: '1', column1: '1', column2: 'Apple', column3: true, myId: '101' }, + { + id: '3', + column1: '11', + column2: 'Banana', + column3: true, + myId: '103', + }, + { + id: '5', + column1: '13', + column2: 'Edamame', + column3: true, + myId: '105', + }, + ]; + fixture.detectChanges(); + await fixture.whenStable(); + + // selectedRowIds should be updated to only include IDs still in the data + expect(component.selectedRowIds()).toEqual(['1', '3', '5']); + }); + + it('should highlight row', async () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + const waitHarness = await TestbedHarnessEnvironment.loader( + fixture, + ).getHarness(SkyWaitHarness.with({ ancestor: '[data-sky-id="grid"]' })); + await expectAsync(waitHarness.isWaiting()).toBeResolvedTo(false); + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getRowNode('2')?.isSelected()).toBeFalse(); + fixture.componentRef.setInput('rowHighlightedId', '2'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getRowNode('2')?.isSelected()).toBeTrue(); + }); + + it('should handle paging without url changes', async () => { + fixture.componentRef.setInput('pageSize', 4); + fixture.detectChanges(); + await fixture.whenStable(); + expect(component).toBeTruthy(); + const pagingHarness = + await TestbedHarnessEnvironment.loader(fixture).getHarness( + SkyPagingHarness, + ); + await expectAsync(pagingHarness.getCurrentPage()).toBeResolvedTo(1); + await pagingHarness.clickNextButton(); + await expectAsync(pagingHarness.getCurrentPage()).toBeResolvedTo(2); + await pagingHarness.clickPreviousButton(); + await expectAsync(pagingHarness.getCurrentPage()).toBeResolvedTo(1); + }); + + describe('apply filters', () => { + it('should apply text filter to grid', async () => { + fixture.componentRef.setInput('showAllGrids', false); + fixture.componentRef.setInput('showFilteredGrid', true); + fixture.detectChanges(); + await fixture.whenStable(); + + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="filtered-grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getDisplayedRowCount()).toBe(7); + + // Apply a text filter + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'column1Filter', + filterValue: { value: ['1'], displayValue: 'Contains "1"' }, + }, + { + filterId: 'column2Filter', + filterValue: { value: 'Ban', displayValue: 'Starts with Ban' }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // Should filter to rows where column2 starts with 'Ban' (Banana) + expect(api?.getDisplayedRowCount()).toBe(2); + }); + + it('should apply multiple filters simultaneously', async () => { + fixture.componentRef.setInput('showAllGrids', false); + fixture.componentRef.setInput('showFilteredGrid', true); + fixture.detectChanges(); + await fixture.whenStable(); + + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="filtered-grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getDisplayedRowCount()).toBe(7); + + // Apply multiple filters + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'column1Filter', + filterValue: { value: '1', displayValue: 'Contains 1' }, + }, + { + filterId: 'column2Filter', + filterValue: { value: 'B', displayValue: 'Starts with B' }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // Should filter to rows where column1 contains '1' AND column2 starts with 'B' + // column1='1' has column2='Apple' (no match) + // column1='01' has column2='Banana' (match) + // column1='11' has column2='Banana' (match) + // column1='12' has column2='Daikon' (no match) + // column1='13' has column2='Edamame' (no match) + expect(api?.getDisplayedRowCount()).toBe(2); + }); + + it('should clear filters when appliedFilters is set to empty', async () => { + fixture.componentRef.setInput('showAllGrids', false); + fixture.componentRef.setInput('showFilteredGrid', true); + fixture.detectChanges(); + await fixture.whenStable(); + + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="filtered-grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getDisplayedRowCount()).toBe(7); + + // Apply filter first + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'column2Filter', + filterValue: { value: 'Ban', displayValue: 'Starts with Ban' }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(2); + + // Clear filters + fixture.componentRef.setInput('appliedFilters', []); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(api?.getDisplayedRowCount()).toBe(7); + }); + + it('should clear filters when appliedFilters is set to undefined', async () => { + fixture.componentRef.setInput('showAllGrids', false); + fixture.componentRef.setInput('showFilteredGrid', true); + fixture.detectChanges(); + await fixture.whenStable(); + + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="filtered-grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getDisplayedRowCount()).toBe(7); + + // Apply filter first + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'column2Filter', + filterValue: { value: 'Ban', displayValue: 'Starts with Ban' }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(2); + + // Clear filters by setting undefined + fixture.componentRef.setInput('appliedFilters', undefined); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(api?.getDisplayedRowCount()).toBe(7); + }); + + it('should ignore filters without matching column filterId', async () => { + fixture.componentRef.setInput('showAllGrids', false); + fixture.componentRef.setInput('showFilteredGrid', true); + fixture.detectChanges(); + await fixture.whenStable(); + + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="filtered-grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getDisplayedRowCount()).toBe(7); + + // Apply a filter with non-existent filterId + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'nonExistentFilter', + filterValue: { value: 'test', displayValue: 'Test' }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // Should not filter any rows since the filterId doesn't match any column + expect(api?.getDisplayedRowCount()).toBe(7); + }); + + it('should ignore filters with undefined value', async () => { + fixture.componentRef.setInput('showAllGrids', false); + fixture.componentRef.setInput('showFilteredGrid', true); + fixture.detectChanges(); + await fixture.whenStable(); + + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="filtered-grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getDisplayedRowCount()).toBe(7); + + // Apply a filter with undefined value + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'column2Filter', + filterValue: undefined, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // Should not filter any rows since the value is undefined + expect(api?.getDisplayedRowCount()).toBe(7); + }); + + it('should update filter when appliedFilters changes', async () => { + fixture.componentRef.setInput('showAllGrids', false); + fixture.componentRef.setInput('showFilteredGrid', true); + fixture.detectChanges(); + await fixture.whenStable(); + + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="filtered-grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + + // Apply initial filter + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'column2Filter', + filterValue: { value: 'A', displayValue: 'Starts with A' }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // Should filter to Apple only + expect(api?.getDisplayedRowCount()).toBe(1); + + // Update filter + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'column2Filter', + filterValue: { value: 'B', displayValue: 'Starts with B' }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // Should now filter to Banana rows + expect(api?.getDisplayedRowCount()).toBe(2); + }); + + it('should apply text filter with operators', async () => { + fixture.componentRef.setInput('showAllGrids', false); + fixture.componentRef.setInput('showFilteredGrid', true); + fixture.detectChanges(); + await fixture.whenStable(); + + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="filtered-grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getDisplayedRowCount()).toBe(7); + + // Apply a text filter using a column without filterOperator (should default to 'contains') + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'column2VarOperatorFilter', + filterValue: { value: 'ana', displayValue: 'Contains ana' }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // Should filter to rows where column2 contains 'ana' (Banana) + expect(api?.getDisplayedRowCount()).toBe(2); + + fixture.componentRef.setInput('textFilterOperator', 'startsWith'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(0); + + fixture.componentRef.setInput('textFilterOperator', 'endsWith'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(2); + + fixture.componentRef.setInput('textFilterOperator', 'notContains'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(5); + + fixture.componentRef.setInput('textFilterOperator', 'equals'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(0); + + fixture.componentRef.setInput('textFilterOperator', 'notEqual'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(7); + + // Does not apply to text filters, but verify no errors occur. + fixture.componentRef.setInput('textFilterOperator', 'lessThanOrEqual'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(7); + expect(loggerSpy).toHaveBeenCalledWith( + `Unsupported text filter operator: lessThanOrEqual`, + ); + + // Update filter to use 'startsWith' operator + fixture.componentRef.setInput('textFilterOperator', 'startsWith'); + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'column2VarOperatorFilter', + filterValue: { value: 'Ban', displayValue: 'Starts with Ban' }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // Should still filter to rows where column2 starts with 'Ban' (Banana) + expect(api?.getDisplayedRowCount()).toBe(2); + + component.dataForFilteredGrid = [ + ...component.dataForFilteredGrid.map((item) => ({ + ...item, + column2: null, + })), + ]; + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(0); + }); + + it('should apply number filter to grid', async () => { + fixture.componentRef.setInput('showAllGrids', false); + fixture.componentRef.setInput('showFilteredGrid', true); + fixture.detectChanges(); + await fixture.whenStable(); + + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="filtered-grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getDisplayedRowCount()).toBe(7); + + // Apply a number filter (equals) + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'numericFilter', + filterValue: { value: 200, displayValue: 'Equals 200' }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // Should filter to rows where numericColumn equals 200 + expect(api?.getDisplayedRowCount()).toBe(1); + + fixture.componentRef.setInput('numberFilterOperator', 'notEqual'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(6); + + fixture.componentRef.setInput('numberFilterOperator', 'lessThanOrEqual'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(5); + + fixture.componentRef.setInput('numberFilterOperator', 'lessThan'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(4); + + fixture.componentRef.setInput( + 'numberFilterOperator', + 'greaterThanOrEqual', + ); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(3); + + fixture.componentRef.setInput('numberFilterOperator', 'greaterThan'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(2); + + // Does not apply to number filters, but verify no errors occur. + fixture.componentRef.setInput('numberFilterOperator', 'contains'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(7); + expect(loggerSpy).toHaveBeenCalledWith( + `Unsupported number or date filter operator: contains`, + ); + }); + + it('should apply number range filter to grid', async () => { + fixture.componentRef.setInput('showAllGrids', false); + fixture.componentRef.setInput('showFilteredGrid', true); + fixture.detectChanges(); + await fixture.whenStable(); + + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="filtered-grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getDisplayedRowCount()).toBe(7); + + // Apply a number range filter + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'numericFilter', + filterValue: { + value: { from: 150, to: 250 }, + displayValue: 'Between 150 and 250', + }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // Should filter to rows where numericColumn is between 150 and 250 (inclusive) + // Values: 100, 200, 150, 250, 175, 300, 125 -> 200, 150, 250, 175 = 4 rows + expect(api?.getDisplayedRowCount()).toBe(4); + + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'numericFilter', + filterValue: { + value: { from: null, to: 250 }, + displayValue: 'Up to 250', + }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(6); + + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'numericFilter', + filterValue: { + value: { from: 150, to: null }, + displayValue: 'Up from 150', + }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(5); + }); + + it('should apply date filter to grid', async () => { + fixture.componentRef.setInput('showAllGrids', false); + fixture.componentRef.setInput('showFilteredGrid', true); + fixture.detectChanges(); + await fixture.whenStable(); + + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="filtered-grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getDisplayedRowCount()).toBe(7); + + // Apply a date filter (equals) + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'dateFilter', + filterValue: { + value: new Date('2024-03-10T00:00:00.000Z'), + displayValue: 'March 10, 2024', + }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // Should filter to rows where dateColumn equals 2024-03-10 + expect(api?.getDisplayedRowCount()).toBe(1); + }); + + it('should apply date range filter to grid', async () => { + fixture.componentRef.setInput('showAllGrids', false); + fixture.componentRef.setInput('showFilteredGrid', true); + fixture.detectChanges(); + await fixture.whenStable(); + + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="filtered-grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getDisplayedRowCount()).toBe(7); + + // Apply a date range filter + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'dateFilter', + filterValue: { + value: { + startDate: new Date('2024-02-01T00:00:00.000Z'), + endDate: new Date('2024-05-01T00:00:00.000Z'), + }, + displayValue: 'Feb 1 to May 1, 2024', + }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // Should filter to rows where dateColumn is between Feb 1 and May 1, 2024 + // Dates: Jan 15, Feb 20, Mar 10, Apr 5, May 25, Jun 30, Jul 12 + // In range: Feb 20, Mar 10, Apr 5 = 3 rows + expect(api?.getDisplayedRowCount()).toBe(3); + + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'dateFilter', + filterValue: { + value: { + endDate: new Date('2024-05-01T00:00:00.000Z'), + }, + displayValue: 'Before May 1, 2024', + }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(4); + }); + + it('should apply boolean filter', async () => { + fixture.componentRef.setInput('showAllGrids', false); + fixture.componentRef.setInput('showFilteredGrid', true); + fixture.detectChanges(); + await fixture.whenStable(); + + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="filtered-grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getDisplayedRowCount()).toBe(7); + + // Apply a boolean filter + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'column3Filter', + filterValue: { value: true, displayValue: 'True' }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // Boolean filters are not supported by AG Grid, so no filtering should occur + expect(api?.getDisplayedRowCount()).toBe(4); + + fixture.componentRef.setInput('booleanFilterOperator', 'notEqual'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(3); + + // Does not apply to boolean filters, but verify no errors occur. + fixture.componentRef.setInput('booleanFilterOperator', 'contains'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(api?.getDisplayedRowCount()).toBe(7); + expect(loggerSpy).toHaveBeenCalledWith( + `Unsupported boolean filter operator: contains`, + ); + }); + + it('should ignore filter when column with filterId does not exist in grid', async () => { + fixture.componentRef.setInput('showAllGrids', false); + fixture.componentRef.setInput('showFilteredGrid', true); + fixture.detectChanges(); + await fixture.whenStable(); + + const api = getGridApi( + fixture.nativeElement.querySelector( + '[data-sky-id="filtered-grid"] ag-grid-angular', + ), + ); + expect(api).toBeTruthy(); + expect(api?.getDisplayedRowCount()).toBe(7); + + // Apply a filter referencing a non-existent column + fixture.componentRef.setInput('appliedFilters', [ + { + filterId: 'nonExistentColumnFilter', + filterValue: { value: 'test', displayValue: 'Test' }, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + // Should not filter any rows since the column doesn't exist + expect(api?.getDisplayedRowCount()).toBe(7); + }); + }); +}); diff --git a/libs/components/data-grid/src/lib/modules/data-grid/data-grid-lite.component.ts b/libs/components/data-grid/src/lib/modules/data-grid/data-grid-lite.component.ts new file mode 100644 index 0000000000..ea07b58dfa --- /dev/null +++ b/libs/components/data-grid/src/lib/modules/data-grid/data-grid-lite.component.ts @@ -0,0 +1,214 @@ +import { + coerceBooleanProperty, + coerceNumberProperty, +} from '@angular/cdk/coercion'; +import { + ChangeDetectionStrategy, + Component, + inject, + input, + model, + output, + signal, +} from '@angular/core'; +import { SkyAgGridModule } from '@skyux/ag-grid'; +import { SkyViewkeeperModule } from '@skyux/core'; +import { SkyWaitModule } from '@skyux/indicators'; +import { SkyFilterStateFilterItem, SkyPagingModule } from '@skyux/lists'; + +import { AgGridAngular } from 'ag-grid-angular'; + +import { SkyDataGridFilterValue } from '../types/data-grid-filter-value'; +import { SkyDataGridRowDeleteCancelArgs } from '../types/data-grid-row-delete-cancel-args'; +import { SkyDataGridRowDeleteConfirmArgs } from '../types/data-grid-row-delete-confirm-args'; + +import { SkyDataGridDirective } from './data-grid.directive'; + +/** + * @preview + */ +@Component({ + selector: 'sky-data-grid-lite', + imports: [ + AgGridAngular, + SkyAgGridModule, + SkyPagingModule, + SkyViewkeeperModule, + SkyWaitModule, + ], + templateUrl: './data-grid-lite.component.html', + styleUrl: './data-grid.component.css', + hostDirectives: [ + { + directive: SkyDataGridDirective, + inputs: [ + 'appliedFilters', + 'compact', + 'data', + 'fit', + 'height', + 'multiselect', + 'pageQueryParam', + 'pageSize', + 'rowDeleteIds', + 'rowHighlightedId', + 'selectedRowIds', + 'stacked', + 'topScrollEnabled', + 'width', + 'useInternalFilters', + ], + outputs: [ + 'rowCountChange', + 'rowDeleteCancel', + 'rowDeleteConfirm', + 'rowDeleteIdsChange', + 'selectedRowIdsChange', + ], + }, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyDataGridLiteComponent< + T extends Record<'id', string> = Record<'id', string> & object, +> { + /** + * The filter state from a + * [`SkyFilterBarComponent`](https://developer.blackbaud.com/skyux/components/filter-bar?docs-active-tab=development#class_sky-filter-bar-component). + * When provided, filters are automatically applied to columns that have matching `filterId` values using the + * respective `SkyDataGridColumnComponent`'s `filterOperator` as the comparator. To use the built-in filters, the + * filter values are: + * + * - For a boolean column, use a `boolean` with `'equals'` or `'notEqual'` as the operator. + * - For a date column, use a [`SkyDateRange`](https://developer.blackbaud.com/skyux/components/date-range-picker?docs-active-tab=development#interface_sky-date-range) or `Date`. + * - For a number column, use a `SkyDataGridNumberRangeFilterValue` or a `number`. + * - For a text column, use `string` or `string[]` as the filter value to match one or more text values. + * + * To provide custom filtering functions, use the `externalRowCount` input and update the `data` input when filters change. + */ + public readonly appliedFilters = input< + SkyFilterStateFilterItem[] + >([]); + + /** + * Enable a compact layout for the grid when using modern theme. Compact layout uses + * a smaller font size and row height to display more data in a smaller space. + * @default false + */ + public readonly compact = input(false, { + transform: coerceBooleanProperty, + }); + + /** + * The data for the grid. Each item requires an `id`, and other properties should map to a `field` of the grid columns. + * When `data` is `null` or `undefined`, the grid will show a loading indicator, and when `data` is an empty array, + * the grid will show a "no rows" message. + */ + public readonly data = input(); + + /** + * How the grid fits to its parent. The valid options are `width`, + * which fits the grid to the parent's full width, and `scroll`, which allows the grid + * to exceed the parent's width. If the grid does not have enough columns to fill + * the parent's width, it always stretches to the parent's full width. + * @default 'width' + */ + public readonly fit = input<'width' | 'scroll'>('width'); + + /** + * The height of the grid in CSS pixels. For best performance, large grids should set a `height` value and not enable + * `wrapText` on any column so that rows can be virtually drawn as needed. When `wrapText` is enabled on any column, + * or when `height` is not set, the grid needs to build every row in order to determine the scroll height, creating + * hundreds or thousands of invisible DOM elements and slowing down the browser. + */ + public readonly height = input(0, { + transform: (val: unknown) => coerceNumberProperty(val, 0), + }); + + /** + * Whether to enable the multiselect feature to display a column of + * checkboxes on the left side of the grid. You can specify a unique ID with + * the `multiselectRowId` property, but multiselect defaults to the `id` property on + * the `data` object. + * @default false + */ + public readonly multiselect = input(false, { + transform: coerceBooleanProperty, + }); + + /** + * The number of items to display per page. Setting this value enables pagination. + */ + public readonly pageSize = input(0, { + transform: (value: unknown) => coerceNumberProperty(value, 0), + }); + + /** + * The query parameter name that stores the current page number. + * When set, the grid syncs page changes to the URL for deep linking, and there should only be one grid on the page. + */ + public readonly pageQueryParam = input(); + + /** + * The ID of the row to highlight. The ID matches the `multiselectRowId` property + * of the `data` object. Typically, this property is used in conjunction with + * the flyout component to indicate the currently selected row. Other rows + * are de-selected in the grid. + */ + public readonly rowHighlightedId = input(); + + /** + * Whether the data grid is stacked with another element below it. When specified, the appropriate + * vertical spacing is automatically added to the data grid. + * @default false + */ + public readonly stacked = input(false, { + transform: coerceBooleanProperty, + }); + + /** + * Move the horizontal scrollbar to just below the header row. + * @default false + */ + public readonly topScrollEnabled = input(false, { + transform: coerceBooleanProperty, + }); + + /** + * The width of the grid in CSS pixels. When no width is set, the grid will use the width of its container. + */ + public readonly width = input(0, { + transform: (val: unknown) => coerceNumberProperty(val, 0), + }); + + /** + * The set of IDs for the rows to prompt for delete confirmation. + * The IDs match the `multiselectRowId` properties of the `data` objects. + */ + public readonly rowDeleteIds = model([]); + + /** + * The set of IDs for the rows to select in a multiselect grid. + * The IDs match the `multiselectRowId` properties of the `data` objects. + * Rows with IDs that are not included are de-selected in the grid. + */ + public readonly selectedRowIds = model([]); + + /** + * Emits a row count after filters are updated. Not used when `externalRowCount` is set. + */ + public readonly rowCountChange = output(); + + /** + * Fires when users cancel the deletion of a row. + */ + public readonly rowDeleteCancel = output(); + + /** + * Fires when users confirm the deletion of a row. + */ + public readonly rowDeleteConfirm = output(); + + protected readonly directive = inject(SkyDataGridDirective, { self: true }); + protected readonly useInternalFilters = signal(true).asReadonly(); +} diff --git a/libs/components/data-grid/src/lib/modules/data-grid/data-grid.component.html b/libs/components/data-grid/src/lib/modules/data-grid/data-grid.component.html index 5850e8e557..d0d0967766 100644 --- a/libs/components/data-grid/src/lib/modules/data-grid/data-grid.component.html +++ b/libs/components/data-grid/src/lib/modules/data-grid/data-grid.component.html @@ -1,10 +1,10 @@ - -@if (gridOptions(); as gridOptions) { - @if (useDataManager) { + +@if (directive.gridOptions(); as gridOptions) { + @if (directive.useDataManager) { @@ -28,22 +28,22 @@ > } - @if (pageCount(); as pageCount) { + @if (directive.pageCount(); as pageCount) { } } diff --git a/libs/components/data-grid/src/lib/modules/data-grid/data-grid.component.spec.ts b/libs/components/data-grid/src/lib/modules/data-grid/data-grid.component.spec.ts index 4c0d86118f..e75873420e 100644 --- a/libs/components/data-grid/src/lib/modules/data-grid/data-grid.component.spec.ts +++ b/libs/components/data-grid/src/lib/modules/data-grid/data-grid.component.spec.ts @@ -27,7 +27,6 @@ describe('SkyDataGridComponent', () => { describe('without data manager', () => { let fixture: ComponentFixture; let component: DataGridTestComponent; - let loggerSpy: jasmine.Spy; let loader: HarnessLoader; beforeEach(() => { @@ -37,8 +36,6 @@ describe('SkyDataGridComponent', () => { fixture = TestBed.createComponent(DataGridTestComponent); loader = TestbedHarnessEnvironment.loader(fixture); component = fixture.componentInstance; - const logger = TestBed.inject(SkyLogService); - loggerSpy = spyOn(logger, 'warn').and.returnValue(undefined); }); it('should create', () => { @@ -776,636 +773,6 @@ describe('SkyDataGridComponent', () => { queryParamsHandling: 'merge', }); }); - - describe('apply filters', () => { - it('should not apply filter when externalRowCount is set', async () => { - fixture.componentRef.setInput('showAllGrids', false); - fixture.componentRef.setInput('showFilteredGrid', true); - fixture.componentRef.setInput('externalRowCount', 123); - fixture.detectChanges(); - await fixture.whenStable(); - - const api = getGridApi( - fixture.nativeElement.querySelector( - '[data-sky-id="filtered-grid"] ag-grid-angular', - ), - ); - expect(api).toBeTruthy(); - expect(api?.getDisplayedRowCount()).toBe(7); - - // Apply a text filter - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'column1Filter', - filterValue: { value: ['1'], displayValue: 'Contains "1"' }, - }, - { - filterId: 'column2Filter', - filterValue: { value: 'Ban', displayValue: 'Starts with Ban' }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - - // Should not filter rows because externalRowCount is set and data has not been updated. - expect(api?.getDisplayedRowCount()).toBe(7); - }); - - it('should apply text filter to grid', async () => { - fixture.componentRef.setInput('showAllGrids', false); - fixture.componentRef.setInput('showFilteredGrid', true); - fixture.detectChanges(); - await fixture.whenStable(); - - const api = getGridApi( - fixture.nativeElement.querySelector( - '[data-sky-id="filtered-grid"] ag-grid-angular', - ), - ); - expect(api).toBeTruthy(); - expect(api?.getDisplayedRowCount()).toBe(7); - - // Apply a text filter - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'column1Filter', - filterValue: { value: ['1'], displayValue: 'Contains "1"' }, - }, - { - filterId: 'column2Filter', - filterValue: { value: 'Ban', displayValue: 'Starts with Ban' }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - - // Should filter to rows where column2 starts with 'Ban' (Banana) - expect(api?.getDisplayedRowCount()).toBe(2); - }); - - it('should apply multiple filters simultaneously', async () => { - fixture.componentRef.setInput('showAllGrids', false); - fixture.componentRef.setInput('showFilteredGrid', true); - fixture.detectChanges(); - await fixture.whenStable(); - - const api = getGridApi( - fixture.nativeElement.querySelector( - '[data-sky-id="filtered-grid"] ag-grid-angular', - ), - ); - expect(api).toBeTruthy(); - expect(api?.getDisplayedRowCount()).toBe(7); - - // Apply multiple filters - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'column1Filter', - filterValue: { value: '1', displayValue: 'Contains 1' }, - }, - { - filterId: 'column2Filter', - filterValue: { value: 'B', displayValue: 'Starts with B' }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - - // Should filter to rows where column1 contains '1' AND column2 starts with 'B' - // column1='1' has column2='Apple' (no match) - // column1='01' has column2='Banana' (match) - // column1='11' has column2='Banana' (match) - // column1='12' has column2='Daikon' (no match) - // column1='13' has column2='Edamame' (no match) - expect(api?.getDisplayedRowCount()).toBe(2); - }); - - it('should clear filters when appliedFilters is set to empty', async () => { - fixture.componentRef.setInput('showAllGrids', false); - fixture.componentRef.setInput('showFilteredGrid', true); - fixture.detectChanges(); - await fixture.whenStable(); - - const api = getGridApi( - fixture.nativeElement.querySelector( - '[data-sky-id="filtered-grid"] ag-grid-angular', - ), - ); - expect(api).toBeTruthy(); - expect(api?.getDisplayedRowCount()).toBe(7); - - // Apply filter first - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'column2Filter', - filterValue: { value: 'Ban', displayValue: 'Starts with Ban' }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(2); - - // Clear filters - fixture.componentRef.setInput('appliedFilters', []); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(api?.getDisplayedRowCount()).toBe(7); - }); - - it('should clear filters when appliedFilters is set to undefined', async () => { - fixture.componentRef.setInput('showAllGrids', false); - fixture.componentRef.setInput('showFilteredGrid', true); - fixture.detectChanges(); - await fixture.whenStable(); - - const api = getGridApi( - fixture.nativeElement.querySelector( - '[data-sky-id="filtered-grid"] ag-grid-angular', - ), - ); - expect(api).toBeTruthy(); - expect(api?.getDisplayedRowCount()).toBe(7); - - // Apply filter first - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'column2Filter', - filterValue: { value: 'Ban', displayValue: 'Starts with Ban' }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(2); - - // Clear filters by setting undefined - fixture.componentRef.setInput('appliedFilters', undefined); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(api?.getDisplayedRowCount()).toBe(7); - }); - - it('should ignore filters without matching column filterId', async () => { - fixture.componentRef.setInput('showAllGrids', false); - fixture.componentRef.setInput('showFilteredGrid', true); - fixture.detectChanges(); - await fixture.whenStable(); - - const api = getGridApi( - fixture.nativeElement.querySelector( - '[data-sky-id="filtered-grid"] ag-grid-angular', - ), - ); - expect(api).toBeTruthy(); - expect(api?.getDisplayedRowCount()).toBe(7); - - // Apply a filter with non-existent filterId - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'nonExistentFilter', - filterValue: { value: 'test', displayValue: 'Test' }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - - // Should not filter any rows since the filterId doesn't match any column - expect(api?.getDisplayedRowCount()).toBe(7); - }); - - it('should ignore filters with undefined value', async () => { - fixture.componentRef.setInput('showAllGrids', false); - fixture.componentRef.setInput('showFilteredGrid', true); - fixture.detectChanges(); - await fixture.whenStable(); - - const api = getGridApi( - fixture.nativeElement.querySelector( - '[data-sky-id="filtered-grid"] ag-grid-angular', - ), - ); - expect(api).toBeTruthy(); - expect(api?.getDisplayedRowCount()).toBe(7); - - // Apply a filter with undefined value - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'column2Filter', - filterValue: undefined, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - - // Should not filter any rows since the value is undefined - expect(api?.getDisplayedRowCount()).toBe(7); - }); - - it('should update filter when appliedFilters changes', async () => { - fixture.componentRef.setInput('showAllGrids', false); - fixture.componentRef.setInput('showFilteredGrid', true); - fixture.detectChanges(); - await fixture.whenStable(); - - const api = getGridApi( - fixture.nativeElement.querySelector( - '[data-sky-id="filtered-grid"] ag-grid-angular', - ), - ); - expect(api).toBeTruthy(); - - // Apply initial filter - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'column2Filter', - filterValue: { value: 'A', displayValue: 'Starts with A' }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - - // Should filter to Apple only - expect(api?.getDisplayedRowCount()).toBe(1); - - // Update filter - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'column2Filter', - filterValue: { value: 'B', displayValue: 'Starts with B' }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - - // Should now filter to Banana rows - expect(api?.getDisplayedRowCount()).toBe(2); - }); - - it('should apply text filter with operators', async () => { - fixture.componentRef.setInput('showAllGrids', false); - fixture.componentRef.setInput('showFilteredGrid', true); - fixture.detectChanges(); - await fixture.whenStable(); - - const api = getGridApi( - fixture.nativeElement.querySelector( - '[data-sky-id="filtered-grid"] ag-grid-angular', - ), - ); - expect(api).toBeTruthy(); - expect(api?.getDisplayedRowCount()).toBe(7); - - // Apply a text filter using a column without filterOperator (should default to 'contains') - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'column2VarOperatorFilter', - filterValue: { value: 'ana', displayValue: 'Contains ana' }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - - // Should filter to rows where column2 contains 'ana' (Banana) - expect(api?.getDisplayedRowCount()).toBe(2); - - fixture.componentRef.setInput('textFilterOperator', 'startsWith'); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(0); - - fixture.componentRef.setInput('textFilterOperator', 'endsWith'); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(2); - - fixture.componentRef.setInput('textFilterOperator', 'notContains'); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(5); - - fixture.componentRef.setInput('textFilterOperator', 'equals'); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(0); - - fixture.componentRef.setInput('textFilterOperator', 'notEqual'); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(7); - - // Does not apply to text filters, but verify no errors occur. - fixture.componentRef.setInput('textFilterOperator', 'lessThanOrEqual'); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(7); - expect(loggerSpy).toHaveBeenCalledWith( - `Unsupported text filter operator: lessThanOrEqual`, - ); - - // Update filter to use 'startsWith' operator - fixture.componentRef.setInput('textFilterOperator', 'startsWith'); - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'column2VarOperatorFilter', - filterValue: { value: 'Ban', displayValue: 'Starts with Ban' }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - - // Should still filter to rows where column2 starts with 'Ban' (Banana) - expect(api?.getDisplayedRowCount()).toBe(2); - - component.dataForFilteredGrid = [ - ...component.dataForFilteredGrid.map((item) => ({ - ...item, - column2: null, - })), - ]; - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(0); - }); - - it('should apply number filter to grid', async () => { - fixture.componentRef.setInput('showAllGrids', false); - fixture.componentRef.setInput('showFilteredGrid', true); - fixture.detectChanges(); - await fixture.whenStable(); - - const api = getGridApi( - fixture.nativeElement.querySelector( - '[data-sky-id="filtered-grid"] ag-grid-angular', - ), - ); - expect(api).toBeTruthy(); - expect(api?.getDisplayedRowCount()).toBe(7); - - // Apply a number filter (equals) - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'numericFilter', - filterValue: { value: 200, displayValue: 'Equals 200' }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - - // Should filter to rows where numericColumn equals 200 - expect(api?.getDisplayedRowCount()).toBe(1); - - fixture.componentRef.setInput('numberFilterOperator', 'notEqual'); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(6); - - fixture.componentRef.setInput( - 'numberFilterOperator', - 'lessThanOrEqual', - ); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(5); - - fixture.componentRef.setInput('numberFilterOperator', 'lessThan'); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(4); - - fixture.componentRef.setInput( - 'numberFilterOperator', - 'greaterThanOrEqual', - ); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(3); - - fixture.componentRef.setInput('numberFilterOperator', 'greaterThan'); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(2); - - // Does not apply to number filters, but verify no errors occur. - fixture.componentRef.setInput('numberFilterOperator', 'contains'); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(7); - expect(loggerSpy).toHaveBeenCalledWith( - `Unsupported number or date filter operator: contains`, - ); - }); - - it('should apply number range filter to grid', async () => { - fixture.componentRef.setInput('showAllGrids', false); - fixture.componentRef.setInput('showFilteredGrid', true); - fixture.detectChanges(); - await fixture.whenStable(); - - const api = getGridApi( - fixture.nativeElement.querySelector( - '[data-sky-id="filtered-grid"] ag-grid-angular', - ), - ); - expect(api).toBeTruthy(); - expect(api?.getDisplayedRowCount()).toBe(7); - - // Apply a number range filter - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'numericFilter', - filterValue: { - value: { from: 150, to: 250 }, - displayValue: 'Between 150 and 250', - }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - - // Should filter to rows where numericColumn is between 150 and 250 (inclusive) - // Values: 100, 200, 150, 250, 175, 300, 125 -> 200, 150, 250, 175 = 4 rows - expect(api?.getDisplayedRowCount()).toBe(4); - - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'numericFilter', - filterValue: { - value: { from: null, to: 250 }, - displayValue: 'Up to 250', - }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(6); - - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'numericFilter', - filterValue: { - value: { from: 150, to: null }, - displayValue: 'Up from 150', - }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(5); - }); - - it('should apply date filter to grid', async () => { - fixture.componentRef.setInput('showAllGrids', false); - fixture.componentRef.setInput('showFilteredGrid', true); - fixture.detectChanges(); - await fixture.whenStable(); - - const api = getGridApi( - fixture.nativeElement.querySelector( - '[data-sky-id="filtered-grid"] ag-grid-angular', - ), - ); - expect(api).toBeTruthy(); - expect(api?.getDisplayedRowCount()).toBe(7); - - // Apply a date filter (equals) - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'dateFilter', - filterValue: { - value: new Date('2024-03-10T00:00:00.000Z'), - displayValue: 'March 10, 2024', - }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - - // Should filter to rows where dateColumn equals 2024-03-10 - expect(api?.getDisplayedRowCount()).toBe(1); - }); - - it('should apply date range filter to grid', async () => { - fixture.componentRef.setInput('showAllGrids', false); - fixture.componentRef.setInput('showFilteredGrid', true); - fixture.detectChanges(); - await fixture.whenStable(); - - const api = getGridApi( - fixture.nativeElement.querySelector( - '[data-sky-id="filtered-grid"] ag-grid-angular', - ), - ); - expect(api).toBeTruthy(); - expect(api?.getDisplayedRowCount()).toBe(7); - - // Apply a date range filter - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'dateFilter', - filterValue: { - value: { - startDate: new Date('2024-02-01T00:00:00.000Z'), - endDate: new Date('2024-05-01T00:00:00.000Z'), - }, - displayValue: 'Feb 1 to May 1, 2024', - }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - - // Should filter to rows where dateColumn is between Feb 1 and May 1, 2024 - // Dates: Jan 15, Feb 20, Mar 10, Apr 5, May 25, Jun 30, Jul 12 - // In range: Feb 20, Mar 10, Apr 5 = 3 rows - expect(api?.getDisplayedRowCount()).toBe(3); - - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'dateFilter', - filterValue: { - value: { - endDate: new Date('2024-05-01T00:00:00.000Z'), - }, - displayValue: 'Before May 1, 2024', - }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(4); - }); - - it('should apply boolean filter', async () => { - fixture.componentRef.setInput('showAllGrids', false); - fixture.componentRef.setInput('showFilteredGrid', true); - fixture.detectChanges(); - await fixture.whenStable(); - - const api = getGridApi( - fixture.nativeElement.querySelector( - '[data-sky-id="filtered-grid"] ag-grid-angular', - ), - ); - expect(api).toBeTruthy(); - expect(api?.getDisplayedRowCount()).toBe(7); - - // Apply a boolean filter - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'column3Filter', - filterValue: { value: true, displayValue: 'True' }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - - // Boolean filters are not supported by AG Grid, so no filtering should occur - expect(api?.getDisplayedRowCount()).toBe(4); - - fixture.componentRef.setInput('booleanFilterOperator', 'notEqual'); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(3); - - // Does not apply to boolean filters, but verify no errors occur. - fixture.componentRef.setInput('booleanFilterOperator', 'contains'); - fixture.detectChanges(); - await fixture.whenStable(); - expect(api?.getDisplayedRowCount()).toBe(7); - expect(loggerSpy).toHaveBeenCalledWith( - `Unsupported boolean filter operator: contains`, - ); - }); - - it('should ignore filter when column with filterId does not exist in grid', async () => { - fixture.componentRef.setInput('showAllGrids', false); - fixture.componentRef.setInput('showFilteredGrid', true); - fixture.detectChanges(); - await fixture.whenStable(); - - const api = getGridApi( - fixture.nativeElement.querySelector( - '[data-sky-id="filtered-grid"] ag-grid-angular', - ), - ); - expect(api).toBeTruthy(); - expect(api?.getDisplayedRowCount()).toBe(7); - - // Apply a filter referencing a non-existent column - fixture.componentRef.setInput('appliedFilters', [ - { - filterId: 'nonExistentColumnFilter', - filterValue: { value: 'test', displayValue: 'Test' }, - }, - ]); - fixture.detectChanges(); - await fixture.whenStable(); - - // Should not filter any rows since the column doesn't exist - expect(api?.getDisplayedRowCount()).toBe(7); - }); - }); }); describe('with data manager', () => { @@ -1459,7 +826,7 @@ describe('SkyDataGridComponent', () => { expect(await grid.getDisplayedColumnIds()).toHaveSize(2); }); - it('should pick up search text from data manager', async () => { + it('should not pick up search text from data manager', async () => { fixture.detectChanges(); await fixture.whenStable(); const gridElement = @@ -1479,7 +846,7 @@ describe('SkyDataGridComponent', () => { await search.enterText('fruit'); fixture.detectChanges(); await fixture.whenStable(); - expect(gridApi?.getGridOption('quickFilterText')).toBe('fruit'); + expect(gridApi?.getGridOption('quickFilterText')).not.toBe('fruit'); }); it('should send sort field update through data manager', async () => { diff --git a/libs/components/data-grid/src/lib/modules/data-grid/data-grid.component.ts b/libs/components/data-grid/src/lib/modules/data-grid/data-grid.component.ts index fa4de5a9cd..4bde3fd2bb 100644 --- a/libs/components/data-grid/src/lib/modules/data-grid/data-grid.component.ts +++ b/libs/components/data-grid/src/lib/modules/data-grid/data-grid.component.ts @@ -1,5 +1,4 @@ import { - coerceArray, coerceBooleanProperty, coerceNumberProperty, coerceStringArray, @@ -7,64 +6,17 @@ import { import { ChangeDetectionStrategy, Component, - computed, - contentChildren, - effect, inject, input, model, output, - signal, - untracked, } from '@angular/core'; -import { - takeUntilDestroyed, - toObservable, - toSignal, -} from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Router } from '@angular/router'; -import { SkyAgGridModule, SkyAgGridService, SkyCellType } from '@skyux/ag-grid'; -import { SkyLogService, SkyViewkeeperModule } from '@skyux/core'; -import { SkyDataManagerService } from '@skyux/data-manager'; +import { SkyAgGridModule } from '@skyux/ag-grid'; +import { SkyViewkeeperModule } from '@skyux/core'; import { SkyWaitModule } from '@skyux/indicators'; -import { - SkyDataHost, - SkyDataHostService, - SkyFilterStateFilterItem, - SkyPagingModule, -} from '@skyux/lists'; +import { SkyFilterStateFilterItem, SkyPagingModule } from '@skyux/lists'; import { AgGridAngular } from 'ag-grid-angular'; -import { - AllCommunityModule, - AutoSizeStrategy, - ColDef, - ColumnMovedEvent, - DisplayedColumnsChangedEvent, - GetRowIdParams, - GridApi, - GridOptions, - GridPreDestroyedEvent, - IRowNode, - ModuleRegistry, - RowSelectionOptions, - SelectionChangedEvent, - SortChangedEvent, - SortDirection, -} from 'ag-grid-community'; -import { - EMPTY, - ObservableInput, - distinctUntilChanged, - filter, - fromEvent, - fromEventPattern, - map, - merge, - startWith, - switchMap, - takeUntil, -} from 'rxjs'; import { SkyDataGridFilterValue } from '../types/data-grid-filter-value'; import { SkyDataGridPageRequest } from '../types/data-grid-page-request'; @@ -72,26 +24,7 @@ import { SkyDataGridRowDeleteCancelArgs } from '../types/data-grid-row-delete-ca import { SkyDataGridRowDeleteConfirmArgs } from '../types/data-grid-row-delete-confirm-args'; import { SkyDataGridSort } from '../types/data-grid-sort'; -import { SkyDataGridColumnInlineHelpComponent } from './data-grid-column-inline-help.component'; -import { SkyDataGridColumnComponent } from './data-grid-column.component'; -import { doesFilterPass } from './data-grid-filter'; - -ModuleRegistry.registerModules([AllCommunityModule]); - -function arraySorted(arr: string[]): string[] { - return arr.slice().sort((a, b) => a.localeCompare(b)); -} - -function arrayIsEqual( - a: string[] | undefined, - b: string[] | undefined, -): boolean { - if (!Array.isArray(a) || !Array.isArray(b) || a?.length !== b?.length) { - return false; - } - const bSorted = arraySorted(b); - return arraySorted(a).every((v, i) => v === bSorted[i]); -} +import { SkyDataGridDirective } from './data-grid.directive'; /** * @preview @@ -107,15 +40,42 @@ function arrayIsEqual( ], templateUrl: './data-grid.component.html', styleUrl: './data-grid.component.css', - host: { - '[class.sky-margin-stacked-lg]': 'stacked()', - '[style.width.px]': 'width() || undefined', - }, + hostDirectives: [ + { + directive: SkyDataGridDirective, + inputs: [ + 'compact', + 'data', + 'displayedColumnIds', + 'externalRowCount', + 'fit', + 'height', + 'multiselect', + 'pageQueryParam', + 'pageSize', + 'rowDeleteIds', + 'rowHighlightedId', + 'selectedRowIds', + 'sortField', + 'stacked', + 'topScrollEnabled', + 'width', + ], + outputs: [ + 'displayedColumnIdsChange', + 'pageRequest', + 'rowDeleteCancel', + 'rowDeleteConfirm', + 'rowDeleteIdsChange', + 'selectedRowIdsChange', + 'sortFieldChange', + ], + }, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SkyDataGridComponent< - T extends Record<'id', string> = Record<'id', string> & - Record, + T extends Record<'id', string> = Record<'id', string> & object, > { /** * The filter state from a @@ -305,593 +265,5 @@ export class SkyDataGridComponent< */ public readonly rowDeleteConfirm = output(); - protected readonly columns = contentChildren(SkyDataGridColumnComponent); - protected readonly gridApi = signal | undefined>(undefined); - protected readonly gridOptions = computed(() => { - const columnDefs = this.#columnDefs(); - if (columnDefs.length === 0) { - return undefined; - } - const { pagination, paginationPageSize } = untracked(() => - this.#paginationOptions(), - ); - const rowData = untracked(() => this.rowData()); - return this.#gridService.getGridOptions({ - gridOptions: { - columnDefs, - context: { - enableTopScroll: untracked(() => this.topScrollEnabled()), - }, - domLayout: untracked(() => this.height()) ? 'normal' : 'autoHeight', - onGridReady: (args) => { - this.gridApi.set(args.api); - this.gridReady.set(true); - }, - pagination, - paginationPageSize, - suppressPaginationPanel: true, - rowData: rowData.length ? rowData : null, - getRowId: (params: GetRowIdParams) => - params.data[ - untracked(() => this.multiselectRowId()) as keyof T - ] as string, - rowSelection: untracked(() => this.#getRowSelection()), - autoSizeStrategy: untracked(() => this.#getAutoSizeStrategy()), - }, - }) as GridOptions; - }); - - protected readonly gridReady = signal(false); - protected readonly rowData = computed(() => { - const pageSize = this.pageSize(); - const useInternalFilters = this.#useInternalFilters(); - let data = this.data() ?? []; - if (pageSize > 0 && !useInternalFilters) { - data = data.slice(0, pageSize); - } - return data; - }); - - protected readonly isExternalFilterPresent = computed(() => { - const hasFilters = (this.appliedFilters() ?? []).length > 0; - const useInternalFilters = this.#useInternalFilters(); - return hasFilters && useInternalFilters; - }); - - protected readonly doesExternalFilterPass = computed( - (): ((node: Pick, 'data'>) => boolean) => { - const appliedFilters = this.appliedFilters(); - const columns = this.columns().map((col) => ({ - filterId: col.filterId(), - field: col.field() as keyof T | undefined, - filterOperator: col.filterOperator(), - type: col.dataType(), - })); - return (node: Pick, 'data'>): boolean => - doesFilterPass( - appliedFilters, - node.data as Record<'id', string> & Partial, - columns, - this.#logger, - ); - }, - ); - - protected readonly pageCount = computed(() => { - const dataLength = this.rowData().length; - const externalRowCount = this.externalRowCount(); - const pageSize = this.pageSize(); - const gridReady = this.gridReady(); - if (!gridReady || pageSize === 0) { - return 0; - } - return Math.ceil((externalRowCount ?? dataLength) / pageSize); - }); - - protected readonly useDataManager = !!inject(SkyDataManagerService, { - optional: true, - }); - protected readonly viewId = computed( - () => this.#dataHostService?.hostId() ?? '', - ); - protected readonly skyViewkeeper = computed(() => { - // Only used when not using SkyDataManagerService because data manager handles SkyViewkeeper. - const classes = ['.ag-header']; - if (this.topScrollEnabled()) { - classes.push('.ag-body-horizontal-scroll'); - } - return classes; - }); - - readonly #activatedRoute = inject(ActivatedRoute, { optional: true }); - readonly #dataHostService = inject(SkyDataHostService, { optional: true }); - readonly #dataHostServiceUpdates = toSignal( - this.#dataHostService?.getDataHostUpdates('SkyDataGridComponent') ?? EMPTY, - ); - readonly #dataHostDisplayedColumnIds = computed( - () => this.#dataHostServiceUpdates()?.displayedColumnIds ?? [], - ); - readonly #dataHostSearchText = computed( - () => this.#dataHostServiceUpdates()?.searchText ?? '', - ); - readonly #gridService = inject(SkyAgGridService); - readonly #logger = inject(SkyLogService); - readonly #router = inject(Router, { optional: true }); - - readonly #columnDefs = computed[]>(() => { - const columns = this.columns(); - const sort = this.sortField(); - const displayed = this.#displayedColumnIds(); - const hidden = this.hiddenColumnIds().filter(Boolean); - return columns.map((col) => - this.#createColDef(col, displayed, hidden, sort), - ); - }); - readonly #displayedColumnIds = computed(() => { - const displayedColumnIds = this.displayedColumnIds().filter(Boolean); - const dataHostDisplayedColumnIds = this.#dataHostDisplayedColumnIds(); - const notHidden = this.columns() - .filter((col) => !col.hidden()) - .map((col) => this.#getColumnIdOrField(col)); - if (dataHostDisplayedColumnIds.length > 0) { - return dataHostDisplayedColumnIds; - } - if (displayedColumnIds.length > 0) { - return displayedColumnIds; - } - return notHidden; - }); - - readonly #dataHost = computed(() => { - const sortField = this.sortField(); - const id = this.viewId(); - const dataHost = untracked(() => this.#dataHostServiceUpdates()); - if (dataHost) { - return { - activeSortOption: sortField - ? { - propertyName: sortField.fieldSelector, - descending: !!sortField.descending, - } - : undefined, - displayedColumnIds: this.#displayedColumnIds(), - id, - page: this.page(), - searchText: dataHost.searchText, - selectedIds: this.selectedRowIds(), - }; - } - return undefined; - }); - - readonly #gridDestroyed = toObservable(this.gridApi).pipe( - filter(Boolean), - switchMap((api) => - fromEventPattern((handler) => - api.addEventListener('gridPreDestroyed', handler), - ), - ), - ); - readonly #gridSelectedRowIds = toObservable(this.gridApi).pipe( - filter(Boolean), - switchMap((api) => - fromEvent(api, 'selectionChanged').pipe( - takeUntil(this.#gridDestroyed), - map((selection) => - arraySorted(this.#getRowIds(selection.selectedNodes)), - ), - distinctUntilChanged(arrayIsEqual), - ), - ), - ); - readonly #gridDisplayedColumnIds = toObservable(this.gridApi).pipe( - filter(Boolean), - switchMap((api) => - merge( - fromEvent(api, 'columnMoved').pipe( - takeUntil(this.#gridDestroyed), - map((columnsEvent) => - columnsEvent.api - .getAllDisplayedColumns() - .map((col) => col.getColId()), - ), - ), - fromEvent( - api, - 'displayedColumnsChanged', - ).pipe( - takeUntil(this.#gridDestroyed), - map((columnsEvent) => - columnsEvent.api - .getAllDisplayedColumns() - .map((col) => col.getColId()), - ), - distinctUntilChanged(arrayIsEqual), - ), - ), - ), - ); - readonly #gridSortChange = toObservable(this.gridApi).pipe( - filter(Boolean), - switchMap((api) => - fromEvent(api, 'sortChanged').pipe( - takeUntil(this.#gridDestroyed), - map((sortEvent): SkyDataGridSort | undefined => { - const sortColumn = sortEvent?.columns?.find((col) => !!col.getSort()); - if (sortColumn) { - return { - descending: sortColumn.getSort() === 'desc', - fieldSelector: sortColumn.getColId(), - }; - } - return undefined; - }), - ), - ), - ); - readonly #paginationOptions = computed(() => { - const pageSize = this.pageSize(); - const hasPageSize = pageSize > 0; - const useInternalFilters = this.#useInternalFilters(); - const pagination = hasPageSize && useInternalFilters; - const paginationPageSize = (pagination && pageSize) || undefined; - return { - pagination, - paginationPageSize, - }; - }); - readonly #useInternalFilters = computed(() => { - const externalRowCount = this.externalRowCount(); - return typeof externalRowCount === 'undefined'; - }); - readonly #queryParamPage = toSignal( - toObservable(this.pageQueryParam).pipe( - switchMap( - (pageQueryParam): ObservableInput => - pageQueryParam && this.#activatedRoute - ? this.#activatedRoute.queryParamMap.pipe( - startWith(this.#activatedRoute.snapshot.queryParamMap), - map((params) => - coerceNumberProperty(params.get(pageQueryParam), 1), - ), - ) - : [], - ), - ), - { initialValue: 1 }, - ); - - constructor() { - // Update specific grid options after the grid has been loaded. - effect(() => { - const api = untracked(() => this.gridApi()); - const columnDefs = this.#columnDefs(); - api?.setGridOption('columnDefs', columnDefs); - }); - effect(() => { - const api = untracked(() => this.gridApi()); - const height = this.height(); - api?.setGridOption('domLayout', height ? 'normal' : 'autoHeight'); - }); - effect(() => { - const api = untracked(() => this.gridApi()); - const loading = (this.data() ?? 'loading') === 'loading'; - api?.setGridOption('loading', loading); - }); - effect(() => { - const api = untracked(() => this.gridApi()); - const { pagination, paginationPageSize } = this.#paginationOptions(); - api?.setGridOption('pagination', pagination); - api?.setGridOption('paginationPageSize', paginationPageSize); - }); - effect(() => { - const api = untracked(() => this.gridApi()); - const rowData = this.rowData(); - api?.setGridOption('rowData', rowData); - }); - effect(() => { - const api = untracked(() => this.gridApi()); - const rowSelection = this.#getRowSelection(); - api?.setGridOption('rowSelection', rowSelection); - }); - - // Apply inputs once the grid is loaded and on subsequent changes. - effect(() => { - const api = this.gridApi(); - const multiselectRowId = this.multiselectRowId(); - const validRowIds = this.rowData().map((row): string => - String(row[multiselectRowId as keyof T]), - ); - const selectedRowIds = coerceStringArray(this.selectedRowIds()); - const validSelectedRowIds = selectedRowIds.filter((id) => - validRowIds.includes(id), - ); - if (!arrayIsEqual(validSelectedRowIds, selectedRowIds)) { - this.selectedRowIds.set(validSelectedRowIds); - } - const currentSelectedRowIds = this.#getRowIds(api?.getSelectedNodes()); - if (!arrayIsEqual(validSelectedRowIds, currentSelectedRowIds)) { - api?.deselectAll(); - validSelectedRowIds.forEach((rowId) => - api?.getRowNode(rowId)?.setSelected(true), - ); - } - }); - effect(() => { - const api = this.gridApi(); - const rowHighlightedId = this.rowHighlightedId(); - this.data(); - if (rowHighlightedId) { - const rowNode = api?.getRowNode(rowHighlightedId); - if (rowNode?.isSelected() === false) { - rowNode?.setSelected(true, true); - } - } - }); - effect(() => { - const api = this.gridApi(); - const page = this.page(); - const pageCount = this.pageCount(); - if (!pageCount || !api) { - return; - } - if (page < 1 || page > pageCount) { - this.page.set(1); - } else { - api.paginationGoToPage(page - 1); - } - }); - - // Sync page from URL query parameter. - effect(() => { - const queryParamPage = this.#queryParamPage(); - this.page.set(queryParamPage); - }); - - this.#gridDestroyed.pipe(takeUntilDestroyed()).subscribe(() => { - this.gridApi.set(undefined); - this.gridReady.set(false); - }); - - // Emit updates from the grid. - this.#gridSelectedRowIds - .pipe( - takeUntilDestroyed(), - map((ids) => coerceStringArray(ids)), - filter((rowIds) => !arrayIsEqual(this.selectedRowIds(), rowIds)), - ) - .subscribe((rowIds) => { - this.selectedRowIds.set(rowIds); - }); - this.#gridDisplayedColumnIds - .pipe( - takeUntilDestroyed(), - map((ids) => coerceStringArray(ids)), - ) - .subscribe((columnIds) => { - this.displayedColumnIdsChange.emit(columnIds); - }); - this.#gridSortChange.pipe(takeUntilDestroyed()).subscribe((sortChange) => { - if (sortChange) { - this.sortField.update((sort) => { - if ( - !!sort !== !!sortChange || - !!sort?.descending !== !!sortChange?.descending || - sort?.fieldSelector !== sortChange?.fieldSelector - ) { - return sortChange; - } - return sort; - }); - } - }); - effect(() => { - const isExternalFilterPresent = this.isExternalFilterPresent(); - const doesExternalFilterPass = this.doesExternalFilterPass(); - let rowData = [...this.rowData()]; - const searchText = this.#dataHostSearchText().normalize().toLowerCase(); - const useInternalFilters = this.#useInternalFilters(); - const multiselectRowId = this.multiselectRowId(); - if (useInternalFilters) { - if (isExternalFilterPresent) { - rowData = rowData.filter((data) => doesExternalFilterPass({ data })); - } - if (searchText) { - rowData = rowData.filter((data) => - Object.values(data).some((value) => - String(value ?? '') - .normalize() - .toLowerCase() - .includes(searchText), - ), - ); - } - const validRowIds = rowData.map((row): string => - String(row[multiselectRowId as keyof T]), - ); - const selectedRowIds = coerceStringArray(this.selectedRowIds()); - const validSelectedRowIds = selectedRowIds.filter((id) => - validRowIds.includes(id), - ); - if (!arrayIsEqual(validSelectedRowIds, selectedRowIds)) { - this.selectedRowIds.set(validSelectedRowIds); - } - this.rowCountChange.emit(rowData.length); - } - }); - - effect(() => { - const searchText = this.#dataHostSearchText(); - const api = this.gridApi(); - const useInternalFilters = this.#useInternalFilters(); - if (useInternalFilters) { - api?.setGridOption('quickFilterText', searchText); - } - }); - - effect(() => { - const dataHost = this.#dataHost(); - if (dataHost) { - this.#dataHostService?.updateDataHost(dataHost, 'SkyDataGridComponent'); - } - }); - - effect(() => { - this.pageRequest.emit({ - pageNumber: this.page(), - pageSize: this.pageSize() || undefined, - sortField: this.sortField(), - }); - }, {}); - } - - protected currentPageChange(page: number): void { - if (page && page !== this.page()) { - const pageQueryParam = this.pageQueryParam(); - if (pageQueryParam) { - // When using a query parameter, send the change through the router. - void this.#router?.navigate([], { - relativeTo: this.#activatedRoute, - queryParams: { - [pageQueryParam]: page === 1 ? null : page, - }, - queryParamsHandling: 'merge', - }); - } else { - this.page.set(page); - } - } - } - - #createColDef( - col: SkyDataGridColumnComponent, - displayed: string[], - hidden: string[], - sort: SkyDataGridSort | undefined, - ): ColDef { - const field = col.field(); - const colDef: ColDef = { - colId: col.columnId(), - field, - headerName: col.headingText(), - headerComponentParams: this.#getHeaderComponentParams(col), - hide: this.#hideColumn(col, displayed, hidden), - resizable: col.resizable(), - sortable: col.sortable(), - lockPosition: col.locked(), - suppressMovable: col.locked(), - type: [], - autoHeight: col.wrapText(), - wrapText: col.wrapText(), - sort: this.#getSort(sort, col), - }; - if (col.dataType() === 'date') { - (colDef.type as string[]).push(SkyCellType.Date); - colDef.cellDataType = 'dateString'; - } else if (field && col.dataType() === 'number') { - (colDef.type as string[]).push(SkyCellType.Number); - colDef.cellDataType = 'number'; - colDef.valueGetter = (params): number => Number(params.data[field]); - } else if (col.dataType() === 'boolean') { - colDef.cellDataType = 'boolean'; - } else { - (colDef.type as string[]).push(SkyCellType.Text); - colDef.cellDataType = 'text'; - } - if (col.cellTemplate()) { - (colDef.type as string[]).push(SkyCellType.Template); - colDef.cellRendererParams = { template: col.cellTemplate }; - } - this.#applyColumnWidthSettings(col, colDef); - return colDef; - } - - #applyColumnWidthSettings( - col: SkyDataGridColumnComponent, - colDef: ColDef, - ): void { - if (col.flexWidth() > -1) { - colDef.initialFlex = col.flexWidth(); - } else if (col.width() > 0) { - colDef.initialWidth = col.width(); - } - if (col.width() > 0) { - colDef.minWidth = col.width(); - colDef.suppressSizeToFit = true; - } - if (!col.resizable() || col.flexWidth() === 0) { - colDef.suppressSizeToFit = true; - colDef.suppressAutoSize = true; - } - } - - #getSort( - sort: SkyDataGridSort | undefined, - col: SkyDataGridColumnComponent, - ): SortDirection { - return sort?.fieldSelector === col.field() - ? sort?.descending - ? 'desc' - : 'asc' - : null; - } - - #getHeaderComponentParams(col: SkyDataGridColumnComponent): object { - return { - headerHidden: col.headingHidden(), - helpPopoverTitle: col.helpPopoverTitle(), - helpPopoverContent: col.helpPopoverContent() || col.description(), - inlineHelpComponent: SkyDataGridColumnInlineHelpComponent, - }; - } - - #getColumnIdOrField(col: SkyDataGridColumnComponent): string { - const id = col.columnId(); - const field = col.field() || ''; - return id || field; - } - - #hideColumn( - col: SkyDataGridColumnComponent, - displayed: string[], - hidden: string[], - ): boolean { - return ( - col.hidden() || - (displayed.length > 0 && - !displayed.includes(this.#getColumnIdOrField(col))) || - hidden.includes(this.#getColumnIdOrField(col)) - ); - } - - #getRowIds(rows: (IRowNode | undefined)[] | null | undefined): string[] { - return coerceArray(rows) - .map((node) => node?.id as string) - .filter(Boolean) as string[]; - } - - #getAutoSizeStrategy(): AutoSizeStrategy { - const width = this.width(); - return this.fit() === 'width' || width - ? { - type: 'fitGridWidth', - } - : { - type: 'fitCellContents', - }; - } - - #getRowSelection(): RowSelectionOptions { - return this.multiselect() - ? { - checkboxes: true, - checkboxLocation: 'selectionColumn', - headerCheckbox: true, - mode: 'multiRow', - } - : { - checkboxes: false, - mode: 'singleRow', - }; - } + protected readonly directive = inject(SkyDataGridDirective, { self: true }); } diff --git a/libs/components/data-grid/src/lib/modules/data-grid/data-grid.directive.ts b/libs/components/data-grid/src/lib/modules/data-grid/data-grid.directive.ts new file mode 100644 index 0000000000..10df9dc895 --- /dev/null +++ b/libs/components/data-grid/src/lib/modules/data-grid/data-grid.directive.ts @@ -0,0 +1,885 @@ +import { + coerceArray, + coerceBooleanProperty, + coerceNumberProperty, + coerceStringArray, +} from '@angular/cdk/coercion'; +import { + Directive, + computed, + contentChildren, + effect, + inject, + input, + model, + output, + signal, + untracked, +} from '@angular/core'; +import { + takeUntilDestroyed, + toObservable, + toSignal, +} from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; +import { SkyAgGridService, SkyCellType } from '@skyux/ag-grid'; +import { SkyLogService } from '@skyux/core'; +import { SkyDataManagerService } from '@skyux/data-manager'; +import { + SkyDataHost, + SkyDataHostService, + SkyFilterStateFilterItem, +} from '@skyux/lists'; + +import { + AllCommunityModule, + AutoSizeStrategy, + ColDef, + ColumnMovedEvent, + DisplayedColumnsChangedEvent, + GetRowIdParams, + GridApi, + GridOptions, + GridPreDestroyedEvent, + IRowNode, + ModuleRegistry, + RowSelectionOptions, + SelectionChangedEvent, + SortChangedEvent, + SortDirection, +} from 'ag-grid-community'; +import { + EMPTY, + Observable, + ObservableInput, + distinctUntilChanged, + filter, + fromEvent, + fromEventPattern, + map, + merge, + startWith, + switchMap, + takeUntil, +} from 'rxjs'; + +import { SkyDataGridFilterValue } from '../types/data-grid-filter-value'; +import { SkyDataGridPageRequest } from '../types/data-grid-page-request'; +import { SkyDataGridRowDeleteCancelArgs } from '../types/data-grid-row-delete-cancel-args'; +import { SkyDataGridRowDeleteConfirmArgs } from '../types/data-grid-row-delete-confirm-args'; +import { SkyDataGridSort } from '../types/data-grid-sort'; + +import { SkyDataGridColumnInlineHelpComponent } from './data-grid-column-inline-help.component'; +import { SkyDataGridColumnComponent } from './data-grid-column.component'; +import { doesFilterPass } from './data-grid-filter'; + +ModuleRegistry.registerModules([AllCommunityModule]); + +function arraySorted(arr: string[]): string[] { + return arr.slice().sort((a, b) => a.localeCompare(b)); +} + +function arrayIsEqual( + a: string[] | undefined, + b: string[] | undefined, +): boolean { + if (!Array.isArray(a) || !Array.isArray(b) || a?.length !== b?.length) { + return false; + } + const bSorted = arraySorted(b); + return arraySorted(a).every((v, i) => v === bSorted[i]); +} + +/** + * @internal + */ +@Directive({ + selector: '[skyDataGrid]', + host: { + '[class.sky-margin-stacked-lg]': 'stacked()', + '[style.width.px]': 'width() || undefined', + }, +}) +export class SkyDataGridDirective< + T extends Record<'id', string> = Record<'id', string> & object, +> { + /** + * The filter state from a + * [`SkyFilterBarComponent`](https://developer.blackbaud.com/skyux/components/filter-bar?docs-active-tab=development#class_sky-filter-bar-component). + * When provided, filters are automatically applied to columns that have matching `filterId` values using the + * respective `SkyDataGridColumnComponent`'s `filterOperator` as the comparator. To use the built-in filters, the + * filter values are: + * + * - For a boolean column, use a `boolean` with `'equals'` or `'notEqual'` as the operator. + * - For a date column, use a [`SkyDateRange`](https://developer.blackbaud.com/skyux/components/date-range-picker?docs-active-tab=development#interface_sky-date-range) or `Date`. + * - For a number column, use a `SkyDataGridNumberRangeFilterValue` or a `number`. + * - For a text column, use `string` or `string[]` as the filter value to match one or more text values. + * + * To provide custom filtering functions, use the `externalRowCount` input and update the `data` input when filters change. + */ + public readonly appliedFilters = input< + SkyFilterStateFilterItem[] + >([]); + + /** + * Enable a compact layout for the grid when using modern theme. Compact layout uses + * a smaller font size and row height to display more data in a smaller space. + * @default false + */ + public readonly compact = input(false, { + transform: coerceBooleanProperty, + }); + + /** + * The data for the grid. Each item requires an `id`, and other properties should map to a `field` of the grid columns. + * When `data` is `null` or `undefined`, the grid will show a loading indicator, and when `data` is an empty array, + * the grid will show a "no rows" message. + */ + public readonly data = input(); + + /** + * The number of records when using a remote data source. + * When `externalRowCount` is set, it is expected that `data` will be updated whenever `pageRequest` emits a new value. + * When both `externalRowCount` and `pageSize` are set, the number of pages is assumed to be `Math.ceil(externalRowCount / pageSize)`. + * If `externalRowCount` is not set, the data grid will page, sort, filter, and apply SKY UX data manager search text to the + * `data` provided, and the row count is assumed to be `data.length`. + */ + public readonly externalRowCount = input(undefined); + + /** + * How the grid fits to its parent. The valid options are `width`, + * which fits the grid to the parent's full width, and `scroll`, which allows the grid + * to exceed the parent's width. If the grid does not have enough columns to fill + * the parent's width, it always stretches to the parent's full width. + * @default 'width' + */ + public readonly fit = input<'width' | 'scroll'>('width'); + + /** + * The height of the grid in CSS pixels. For best performance, large grids should set a `height` value and not enable + * `wrapText` on any column so that rows can be virtually drawn as needed. When `wrapText` is enabled on any column, + * or when `height` is not set, the grid needs to build every row in order to determine the scroll height, creating + * hundreds or thousands of invisible DOM elements and slowing down the browser. + */ + public readonly height = input(0, { + transform: (val: unknown) => coerceNumberProperty(val, 0), + }); + + /** + * The column IDs or fields for columns to hide. Should not be combined with `displayedColumnIds`. + */ + public readonly hiddenColumnIds = input([], { + transform: coerceStringArray, + }); + + /** + * Whether to enable the multiselect feature to display a column of + * checkboxes on the left side of the grid. You can specify a unique ID with + * the `multiselectRowId` property, but multiselect defaults to the `id` property on + * the `data` object. + * @default false + */ + public readonly multiselect = input(false, { + transform: coerceBooleanProperty, + }); + + /** + * The unique ID that matches a property on the `data` object. + * @default 'id' + */ + public readonly multiselectRowId = input('id'); + + /** + * The number of items to display per page. Setting this value enables pagination. + */ + public readonly pageSize = input(0, { + transform: (value: unknown) => coerceNumberProperty(value, 0), + }); + + /** + * The query parameter name that stores the current page number. + * When set, the grid syncs page changes to the URL for deep linking, and there should only be one grid on the page. + */ + public readonly pageQueryParam = input(); + + /** + * The ID of the row to highlight. The ID matches the `multiselectRowId` property + * of the `data` object. Typically, this property is used in conjunction with + * the flyout component to indicate the currently selected row. Other rows + * are de-selected in the grid. + */ + public readonly rowHighlightedId = input(); + + /** + * Whether the data grid is stacked with another element below it. When specified, the appropriate + * vertical spacing is automatically added to the data grid. + * @default false + */ + public readonly stacked = input(false, { + transform: coerceBooleanProperty, + }); + + /** + * Move the horizontal scrollbar to just below the header row. + * @default false + */ + public readonly topScrollEnabled = input(false, { + transform: coerceBooleanProperty, + }); + + /** + * The width of the grid in CSS pixels. When no width is set, the grid will use the width of its container. + */ + public readonly width = input(0, { + transform: (val: unknown) => coerceNumberProperty(val, 0), + }); + + /** + * The column IDs or fields for columns to show. Should not be combined with `hiddenColumnIds`. + */ + public readonly displayedColumnIds = input([], { + transform: coerceStringArray, + }); + + /** + * Fires when columns change. This includes changes to the displayed columns and changes + * to the order of columns. The event emits an array of IDs for the displayed columns that + * reflects the column order. + */ + public readonly displayedColumnIdsChange = output(); + + /** + * The current page number of the grid when `pageSize` has been set. + */ + public readonly page = model(1); + + /** + * The set of IDs for the rows to prompt for delete confirmation. + * The IDs match the `multiselectRowId` properties of the `data` objects. + */ + public readonly rowDeleteIds = model([]); + + /** + * The set of IDs for the rows to select in a multiselect grid. + * The IDs match the `multiselectRowId` properties of the `data` objects. + * Rows with IDs that are not included are de-selected in the grid. + */ + public readonly selectedRowIds = model([]); + + /** + * The sort setting for the grid. + */ + public readonly sortField = model(undefined); + + /** + * Fires when sorting or page number changes. + */ + public readonly pageRequest = output(); + + /** + * Emits a row count after filters are updated. Not used when `externalRowCount` is set. + */ + public readonly rowCountChange = output(); + + /** + * Fires when users cancel the deletion of a row. + */ + public readonly rowDeleteCancel = output(); + + /** + * Fires when users confirm the deletion of a row. + */ + public readonly rowDeleteConfirm = output(); + + protected readonly columns = contentChildren(SkyDataGridColumnComponent); + protected readonly gridApi = signal | undefined>(undefined); + public readonly gridOptions = computed(() => { + const columnDefs = this.#columnDefs(); + if (columnDefs.length === 0) { + return undefined; + } + const { pagination, paginationPageSize } = untracked(() => + this.#paginationOptions(), + ); + const rowData = untracked(() => this.rowData()); + return this.#gridService.getGridOptions({ + gridOptions: { + columnDefs, + context: { + enableTopScroll: untracked(() => this.topScrollEnabled()), + }, + domLayout: untracked(() => this.height()) ? 'normal' : 'autoHeight', + onGridReady: (args) => { + this.gridApi.set(args.api); + this.gridReady.set(true); + }, + pagination, + paginationPageSize, + suppressPaginationPanel: true, + rowData: rowData.length ? rowData : null, + getRowId: (params: GetRowIdParams) => + params.data[ + untracked(() => this.multiselectRowId()) as keyof T + ] as string, + rowSelection: untracked(() => this.#getRowSelection()), + autoSizeStrategy: untracked(() => this.#getAutoSizeStrategy()), + }, + }) as GridOptions; + }); + + public readonly gridReady = signal(false); + protected readonly rowData = computed(() => { + const pageSize = this.pageSize(); + const useInternalFilters = this.useInternalFilters(); + let data = this.data() ?? []; + if (pageSize > 0 && !useInternalFilters) { + data = data.slice(0, pageSize); + } + return data; + }); + + public readonly isExternalFilterPresent = computed(() => { + const hasFilters = (this.appliedFilters() ?? []).length > 0; + const useInternalFilters = this.useInternalFilters(); + return hasFilters && useInternalFilters; + }); + + public readonly doesExternalFilterPass = computed( + (): ((node: Pick, 'data'>) => boolean) => { + const appliedFilters = this.appliedFilters(); + const columns = this.columns().map((col) => ({ + filterId: col.filterId(), + field: col.field() as keyof T | undefined, + filterOperator: col.filterOperator(), + type: col.dataType(), + })); + return (node: Pick, 'data'>): boolean => + doesFilterPass( + appliedFilters, + node.data as Record<'id', string> & Partial, + columns, + this.#logger, + ); + }, + ); + + public readonly pageCount = computed(() => { + const dataLength = this.rowData().length; + const externalRowCount = this.externalRowCount(); + const pageSize = this.pageSize(); + const gridReady = this.gridReady(); + if (!gridReady || pageSize === 0) { + return 0; + } + return Math.ceil((externalRowCount ?? dataLength) / pageSize); + }); + + public readonly useDataManager = !!inject(SkyDataManagerService, { + optional: true, + }); + public readonly viewId = computed( + () => this.#dataHostService?.hostId() ?? '', + ); + public readonly skyViewkeeper = computed(() => { + // Only used when not using SkyDataManagerService because data manager handles SkyViewkeeper. + const classes = ['.ag-header']; + if (this.topScrollEnabled()) { + classes.push('.ag-body-horizontal-scroll'); + } + return classes; + }); + + readonly #activatedRoute = inject(ActivatedRoute, { optional: true }); + readonly #dataHostService = inject(SkyDataHostService, { optional: true }); + readonly #dataHostServiceUpdates = toSignal( + this.#dataHostService?.getDataHostUpdates('SkyDataGridComponent') ?? EMPTY, + ); + readonly #dataHostDisplayedColumnIds = computed( + () => this.#dataHostServiceUpdates()?.displayedColumnIds ?? [], + ); + readonly #dataHostSearchText = computed( + () => this.#dataHostServiceUpdates()?.searchText ?? '', + ); + readonly #gridService = inject(SkyAgGridService); + readonly #logger = inject(SkyLogService); + readonly #router = inject(Router, { optional: true }); + + readonly #columnDefs = computed[]>(() => { + const columns = this.columns(); + const sort = this.sortField(); + const displayed = this.#displayedColumnIds(); + const hidden = this.hiddenColumnIds().filter(Boolean); + return columns.map((col) => + this.#createColDef(col, displayed, hidden, sort), + ); + }); + readonly #displayedColumnIds = computed(() => { + const displayedColumnIds = this.displayedColumnIds().filter(Boolean); + const dataHostDisplayedColumnIds = this.#dataHostDisplayedColumnIds(); + const notHidden = this.columns() + .filter((col) => !col.hidden()) + .map((col) => this.#getColumnIdOrField(col)); + if (dataHostDisplayedColumnIds.length > 0) { + return dataHostDisplayedColumnIds; + } + if (displayedColumnIds.length > 0) { + return displayedColumnIds; + } + return notHidden; + }); + + readonly #dataHost = computed(() => { + const sortField = this.sortField(); + const id = this.viewId(); + const dataHost = untracked(() => this.#dataHostServiceUpdates()); + if (dataHost) { + return { + activeSortOption: sortField + ? { + propertyName: sortField.fieldSelector, + descending: !!sortField.descending, + } + : undefined, + displayedColumnIds: this.#displayedColumnIds(), + id, + page: this.page(), + searchText: dataHost.searchText, + selectedIds: this.selectedRowIds(), + }; + } + return undefined; + }); + + readonly #gridDestroyed = toObservable(this.gridApi).pipe( + filter(Boolean), + switchMap((api) => + fromEventPattern((handler) => + api.addEventListener('gridPreDestroyed', handler), + ), + ), + ); + readonly #gridSelectedRowIds = toObservable(this.gridApi).pipe( + filter(Boolean), + switchMap( + (api) => + fromEvent(api, 'selectionChanged').pipe( + takeUntil(this.#gridDestroyed), + map((selection) => + arraySorted(this.#getRowIds(selection.selectedNodes)), + ), + map((ids) => coerceStringArray(ids)), + distinctUntilChanged(arrayIsEqual), + ) as Observable, + ), + ); + readonly #gridDisplayedColumnIds = toObservable(this.gridApi).pipe( + filter(Boolean), + switchMap((api) => + merge( + fromEvent(api, 'columnMoved').pipe( + takeUntil(this.#gridDestroyed), + map((columnsEvent) => + columnsEvent.api + .getAllDisplayedColumns() + .map((col) => col.getColId()), + ), + ), + fromEvent( + api, + 'displayedColumnsChanged', + ).pipe( + takeUntil(this.#gridDestroyed), + map((columnsEvent) => + columnsEvent.api + .getAllDisplayedColumns() + .map((col) => col.getColId()), + ), + distinctUntilChanged(arrayIsEqual), + ), + ), + ), + ); + readonly #gridSortChange = toObservable(this.gridApi).pipe( + filter(Boolean), + switchMap((api) => + fromEvent(api, 'sortChanged').pipe( + takeUntil(this.#gridDestroyed), + map((sortEvent): SkyDataGridSort | undefined => { + const sortColumn = sortEvent?.columns?.find((col) => !!col.getSort()); + if (sortColumn) { + return { + descending: sortColumn.getSort() === 'desc', + fieldSelector: sortColumn.getColId(), + }; + } + return undefined; + }), + ), + ), + ); + readonly #paginationOptions = computed(() => { + const pageSize = this.pageSize(); + const hasPageSize = pageSize > 0; + const useInternalFilters = this.useInternalFilters(); + const pagination = hasPageSize && useInternalFilters; + const paginationPageSize = (pagination && pageSize) || undefined; + return { + pagination, + paginationPageSize, + }; + }); + /** + * When `true`, the grid applies external filters and search text internally to the provided `data` and handles pagination, sorting, and emitting row count changes based on the filtered data. + * When `false`, the grid expects that all filtering, searching, pagination, and sorting are handled externally, by updating the `data` input in response to changes to `appliedFilters`, search text from a `SkyFilterBarComponent`, or page changes. + */ + public readonly useInternalFilters = input(false); + readonly #queryParamPage = toSignal( + toObservable(this.pageQueryParam).pipe( + switchMap( + (pageQueryParam): ObservableInput => + pageQueryParam && this.#activatedRoute + ? this.#activatedRoute.queryParamMap.pipe( + startWith(this.#activatedRoute.snapshot.queryParamMap), + map((params) => + coerceNumberProperty(params.get(pageQueryParam), 1), + ), + ) + : [], + ), + ), + { initialValue: 1 }, + ); + + constructor() { + // Update specific grid options after the grid has been loaded. + effect(() => { + const api = untracked(() => this.gridApi()); + const columnDefs = this.#columnDefs(); + api?.setGridOption('columnDefs', columnDefs); + }); + effect(() => { + const api = untracked(() => this.gridApi()); + const height = this.height(); + api?.setGridOption('domLayout', height ? 'normal' : 'autoHeight'); + }); + effect(() => { + const api = untracked(() => this.gridApi()); + const loading = (this.data() ?? 'loading') === 'loading'; + api?.setGridOption('loading', loading); + }); + effect(() => { + const api = untracked(() => this.gridApi()); + const { pagination, paginationPageSize } = this.#paginationOptions(); + api?.setGridOption('pagination', pagination); + api?.setGridOption('paginationPageSize', paginationPageSize); + }); + effect(() => { + const api = untracked(() => this.gridApi()); + const rowData = this.rowData(); + api?.setGridOption('rowData', rowData); + }); + effect(() => { + const api = untracked(() => this.gridApi()); + const rowSelection = this.#getRowSelection(); + api?.setGridOption('rowSelection', rowSelection); + }); + + // Apply inputs once the grid is loaded and on subsequent changes. + effect(() => { + const api = this.gridApi(); + const multiselectRowId = this.multiselectRowId(); + const validRowIds = this.rowData().map((row): string => + String(row[multiselectRowId as keyof T]), + ); + const selectedRowIds = coerceStringArray(this.selectedRowIds()); + const validSelectedRowIds = selectedRowIds.filter((id) => + validRowIds.includes(id), + ); + if (!arrayIsEqual(validSelectedRowIds, selectedRowIds)) { + this.selectedRowIds.set(validSelectedRowIds); + } + const currentSelectedRowIds = this.#getRowIds(api?.getSelectedNodes()); + if (!arrayIsEqual(validSelectedRowIds, currentSelectedRowIds)) { + api?.deselectAll(); + validSelectedRowIds.forEach((rowId) => + api?.getRowNode(rowId)?.setSelected(true), + ); + } + }); + effect(() => { + const api = this.gridApi(); + const rowHighlightedId = this.rowHighlightedId(); + this.data(); + if (rowHighlightedId) { + const rowNode = api?.getRowNode(rowHighlightedId); + if (rowNode?.isSelected() === false) { + rowNode?.setSelected(true, true); + } + } + }); + effect(() => { + const api = this.gridApi(); + const page = this.page(); + const pageCount = this.pageCount(); + if (!pageCount || !api) { + return; + } + if (page < 1 || page > pageCount) { + this.page.set(1); + } else { + api.paginationGoToPage(page - 1); + } + }); + + // Sync page from URL query parameter. + effect(() => { + const queryParamPage = this.#queryParamPage(); + this.page.set(queryParamPage); + }); + + this.#gridDestroyed.pipe(takeUntilDestroyed()).subscribe(() => { + this.gridApi.set(undefined); + this.gridReady.set(false); + }); + + // Emit updates from the grid. + this.#gridSelectedRowIds + .pipe( + takeUntilDestroyed(), + filter((rowIds) => !arrayIsEqual(this.selectedRowIds(), rowIds)), + ) + .subscribe((rowIds) => { + this.selectedRowIds.set(rowIds); + }); + this.#gridDisplayedColumnIds + .pipe( + takeUntilDestroyed(), + map((ids) => coerceStringArray(ids)), + ) + .subscribe((columnIds) => { + this.displayedColumnIdsChange.emit(columnIds); + }); + this.#gridSortChange.pipe(takeUntilDestroyed()).subscribe((sortChange) => { + if (sortChange) { + this.sortField.update((sort) => { + if ( + !!sort !== !!sortChange || + !!sort?.descending !== !!sortChange?.descending || + sort?.fieldSelector !== sortChange?.fieldSelector + ) { + return sortChange; + } + return sort; + }); + } + }); + effect(() => { + const isExternalFilterPresent = this.isExternalFilterPresent(); + const doesExternalFilterPass = this.doesExternalFilterPass(); + let rowData = [...this.rowData()]; + const searchText = this.#dataHostSearchText().normalize().toLowerCase(); + const useInternalFilters = this.useInternalFilters(); + const multiselectRowId = this.multiselectRowId(); + if (useInternalFilters) { + if (isExternalFilterPresent) { + rowData = rowData.filter((data) => doesExternalFilterPass({ data })); + } + if (searchText) { + rowData = rowData.filter((data) => + Object.values(data).some((value) => + String(value ?? '') + .normalize() + .toLowerCase() + .includes(searchText), + ), + ); + } + const validRowIds = rowData.map((row): string => + String(row[multiselectRowId as keyof T]), + ); + const selectedRowIds = coerceStringArray(this.selectedRowIds()); + const validSelectedRowIds = selectedRowIds.filter((id) => + validRowIds.includes(id), + ); + if (!arrayIsEqual(validSelectedRowIds, selectedRowIds)) { + this.selectedRowIds.set(validSelectedRowIds); + } + this.rowCountChange.emit(rowData.length); + } + }); + + effect(() => { + const searchText = this.#dataHostSearchText(); + const api = this.gridApi(); + const useInternalFilters = this.useInternalFilters(); + if (useInternalFilters) { + api?.setGridOption('quickFilterText', searchText); + } + }); + + effect(() => { + const dataHost = this.#dataHost(); + if (dataHost) { + this.#dataHostService?.updateDataHost(dataHost, 'SkyDataGridComponent'); + } + }); + + effect(() => { + this.pageRequest.emit({ + pageNumber: this.page(), + pageSize: this.pageSize() || undefined, + sortField: this.sortField(), + }); + }); + } + + public currentPageChange(page: number): void { + if (page && page !== this.page()) { + const pageQueryParam = this.pageQueryParam(); + if (pageQueryParam) { + // When using a query parameter, send the change through the router. + void this.#router?.navigate([], { + relativeTo: this.#activatedRoute, + queryParams: { + [pageQueryParam]: page === 1 ? null : page, + }, + queryParamsHandling: 'merge', + }); + } else { + this.page.set(page); + } + } + } + + #createColDef( + col: SkyDataGridColumnComponent, + displayed: string[], + hidden: string[], + sort: SkyDataGridSort | undefined, + ): ColDef { + const field = col.field(); + const colDef: ColDef = { + colId: col.columnId(), + field, + headerName: col.headingText(), + headerComponentParams: this.#getHeaderComponentParams(col), + hide: this.#hideColumn(col, displayed, hidden), + resizable: col.resizable(), + sortable: col.sortable(), + lockPosition: col.locked(), + suppressMovable: col.locked(), + type: [], + autoHeight: col.wrapText(), + wrapText: col.wrapText(), + sort: this.#getSort(sort, col), + }; + if (col.dataType() === 'date') { + (colDef.type as string[]).push(SkyCellType.Date); + colDef.cellDataType = 'dateString'; + } else if (field && col.dataType() === 'number') { + (colDef.type as string[]).push(SkyCellType.Number); + colDef.cellDataType = 'number'; + colDef.valueGetter = (params): number => Number(params.data[field]); + } else if (col.dataType() === 'boolean') { + colDef.cellDataType = 'boolean'; + } else { + (colDef.type as string[]).push(SkyCellType.Text); + colDef.cellDataType = 'text'; + } + if (col.cellTemplate()) { + (colDef.type as string[]).push(SkyCellType.Template); + colDef.cellRendererParams = { template: col.cellTemplate }; + } + this.#applyColumnWidthSettings(col, colDef); + return colDef; + } + + #applyColumnWidthSettings( + col: SkyDataGridColumnComponent, + colDef: ColDef, + ): void { + if (col.flexWidth() > -1) { + colDef.initialFlex = col.flexWidth(); + } else if (col.width() > 0) { + colDef.initialWidth = col.width(); + } + if (col.width() > 0) { + colDef.minWidth = col.width(); + colDef.suppressSizeToFit = true; + } + if (!col.resizable() || col.flexWidth() === 0) { + colDef.suppressSizeToFit = true; + colDef.suppressAutoSize = true; + } + } + + #getSort( + sort: SkyDataGridSort | undefined, + col: SkyDataGridColumnComponent, + ): SortDirection { + return sort?.fieldSelector === col.field() + ? sort?.descending + ? 'desc' + : 'asc' + : null; + } + + #getHeaderComponentParams(col: SkyDataGridColumnComponent): object { + return { + headerHidden: col.headingHidden(), + helpPopoverTitle: col.helpPopoverTitle(), + helpPopoverContent: col.helpPopoverContent() || col.description(), + inlineHelpComponent: SkyDataGridColumnInlineHelpComponent, + }; + } + + #getColumnIdOrField(col: SkyDataGridColumnComponent): string { + const id = col.columnId(); + const field = col.field() || ''; + return id || field; + } + + #hideColumn( + col: SkyDataGridColumnComponent, + displayed: string[], + hidden: string[], + ): boolean { + return ( + col.hidden() || + (displayed.length > 0 && + !displayed.includes(this.#getColumnIdOrField(col))) || + hidden.includes(this.#getColumnIdOrField(col)) + ); + } + + #getRowIds(rows: (IRowNode | undefined)[] | null | undefined): string[] { + return coerceArray(rows) + .map((node) => node?.id as string) + .filter(Boolean) as string[]; + } + + #getAutoSizeStrategy(): AutoSizeStrategy { + const width = this.width(); + return this.fit() === 'width' || width + ? { + type: 'fitGridWidth', + } + : { + type: 'fitCellContents', + }; + } + + #getRowSelection(): RowSelectionOptions { + return this.multiselect() + ? { + checkboxes: true, + checkboxLocation: 'selectionColumn', + headerCheckbox: true, + mode: 'multiRow', + } + : { + checkboxes: false, + mode: 'singleRow', + }; + } +} diff --git a/libs/components/data-grid/src/lib/modules/data-grid/data-grid.module.ts b/libs/components/data-grid/src/lib/modules/data-grid/data-grid.module.ts index bb2bcee5f5..21e039e362 100644 --- a/libs/components/data-grid/src/lib/modules/data-grid/data-grid.module.ts +++ b/libs/components/data-grid/src/lib/modules/data-grid/data-grid.module.ts @@ -1,13 +1,22 @@ import { NgModule } from '@angular/core'; import { SkyDataGridColumnComponent } from './data-grid-column.component'; +import { SkyDataGridLiteComponent } from './data-grid-lite.component'; import { SkyDataGridComponent } from './data-grid.component'; /** * @preview */ @NgModule({ - exports: [SkyDataGridComponent, SkyDataGridColumnComponent], - imports: [SkyDataGridComponent, SkyDataGridColumnComponent], + exports: [ + SkyDataGridComponent, + SkyDataGridColumnComponent, + SkyDataGridLiteComponent, + ], + imports: [ + SkyDataGridComponent, + SkyDataGridColumnComponent, + SkyDataGridLiteComponent, + ], }) export class SkyDataGridModule {} diff --git a/libs/components/data-grid/src/lib/modules/data-grid/fixtures/data-grid-lite-test.component.html b/libs/components/data-grid/src/lib/modules/data-grid/fixtures/data-grid-lite-test.component.html new file mode 100644 index 0000000000..45b4b681df --- /dev/null +++ b/libs/components/data-grid/src/lib/modules/data-grid/fixtures/data-grid-lite-test.component.html @@ -0,0 +1,238 @@ +@if (showAllGrids()) { + + @if (showAllColumns()) { + + + @if (showCol3()) { + + } + } + +} +Grid with multiselect enabled + + + Select all + + + + Clear all + + + + Programmatically select rows 101, 103, and 105 + + +@if (showAllGrids()) { + + @if (showAllColumns()) { + + + + @if (showCol3()) { + + } + } + +} +Selected rows: {{ selectedRowIds() | json }} + +Grid with row delete + +@if (showAllGrids()) { + + @if (showAllColumns()) { + + + + @if (row.id) { + + + + + Delete item + + + + + } + + + + + @if (showCol3()) { + + } @else { + + } + } + +} + + This is a numeric column. Click here to learn more. + + +Grid w/ aligned columns and inline help + +@if (showAllGrids()) { + + @if (showAllColumns()) { + + + @if (showCol3()) { + + } + } + +} + +@if (showFilteredMultiselectGrid()) { + Grid with filters and multiselect + + + + +} + +@if (showFilteredGrid()) { + Grid with filters + + + + + + + + +} diff --git a/libs/components/data-grid/src/lib/modules/data-grid/fixtures/data-grid-lite-test.component.ts b/libs/components/data-grid/src/lib/modules/data-grid/fixtures/data-grid-lite-test.component.ts new file mode 100644 index 0000000000..4e4778b49f --- /dev/null +++ b/libs/components/data-grid/src/lib/modules/data-grid/fixtures/data-grid-lite-test.component.ts @@ -0,0 +1,194 @@ +import { JsonPipe } from '@angular/common'; +import { + ChangeDetectorRef, + Component, + inject, + input, + model, +} from '@angular/core'; +import { SkyFilterBarFilterItem } from '@skyux/filter-bar'; +import { SkyDropdownModule, SkyPopoverModule } from '@skyux/popovers'; + +import { SkyDataGridFilterOperator } from '../../types/data-grid-filter-operator'; +import { SkyDataGridFilterValue } from '../../types/data-grid-filter-value'; +import { SkyDataGridRowDeleteCancelArgs } from '../../types/data-grid-row-delete-cancel-args'; +import { SkyDataGridRowDeleteConfirmArgs } from '../../types/data-grid-row-delete-confirm-args'; +import { SkyDataGridColumnComponent } from '../data-grid-column.component'; +import { SkyDataGridLiteComponent } from '../data-grid-lite.component'; + +interface RowModel { + id: string; + column1: string; + column2: string; + column3: boolean; + myId?: string; +} + +interface FilteredRowModel { + id: string; + column1: string; + column2: string | null; + column3: boolean; + numericColumn: number; + dateColumn: string; +} + +@Component({ + selector: 'app-data-grid-lite-test', + templateUrl: './data-grid-lite-test.component.html', + imports: [ + SkyDataGridLiteComponent, + SkyDataGridColumnComponent, + SkyPopoverModule, + SkyDropdownModule, + JsonPipe, + ], +}) +export class DataGridLiteTestComponent { + public dataForRowDeleteGrid: RowModel[] | undefined = [ + { id: '1', column1: '1', column2: 'Apple', column3: true }, + { id: '2', column1: '01', column2: 'Banana', column3: false }, + { id: '3', column1: '11', column2: 'Banana', column3: true }, + { id: '4', column1: '12', column2: 'Daikon', column3: false }, + { id: '5', column1: '13', column2: 'Edamame', column3: true }, + { id: '6', column1: '20', column2: 'Fig', column3: false }, + { id: '7', column1: '21', column2: 'Grape', column3: true }, + ]; + + public dataForSimpleGrid: RowModel[] | undefined = [ + { id: '1', column1: '1', column2: 'Apple', column3: true }, + { id: '2', column1: '01', column2: 'Banana', column3: false }, + { id: '3', column1: '11', column2: 'Banana', column3: true }, + { id: '4', column1: '12', column2: 'Daikon', column3: false }, + { id: '5', column1: '13', column2: 'Edamame', column3: true }, + { id: '6', column1: '20', column2: 'Fig', column3: false }, + { id: '7', column1: '21', column2: 'Grape', column3: true }, + ]; + + public dataForSimpleGridWithMultiselect: RowModel[] | undefined = [ + { id: '1', column1: '1', column2: 'Apple', column3: true, myId: '101' }, + { id: '2', column1: '01', column2: 'Banana', column3: false, myId: '102' }, + { id: '3', column1: '11', column2: 'Banana', column3: true, myId: '103' }, + { id: '4', column1: '12', column2: 'Daikon', column3: false, myId: '104' }, + { id: '5', column1: '13', column2: 'Edamame', column3: true, myId: '105' }, + { id: '6', column1: '20', column2: 'Fig', column3: false, myId: '106' }, + { id: '7', column1: '21', column2: 'Grape', column3: true, myId: '107' }, + ]; + + public dataForFilteredGrid: FilteredRowModel[] = [ + { + id: '1', + column1: '1', + column2: 'Apple', + column3: true, + numericColumn: 100, + dateColumn: new Date('2024-01-15T00:00:00.000Z').toISOString(), + }, + { + id: '2', + column1: '01', + column2: 'Banana', + column3: false, + numericColumn: 200, + dateColumn: new Date('2024-02-20T00:00:00.000Z').toISOString(), + }, + { + id: '3', + column1: '11', + column2: 'Banana', + column3: true, + numericColumn: 150, + dateColumn: new Date('2024-03-10T00:00:00.000Z').toISOString(), + }, + { + id: '4', + column1: '12', + column2: 'Daikon', + column3: false, + numericColumn: 250, + dateColumn: new Date('2024-04-05T00:00:00.000Z').toISOString(), + }, + { + id: '5', + column1: '13', + column2: 'Edamame', + column3: true, + numericColumn: 175, + dateColumn: new Date('2024-05-25T00:00:00.000Z').toISOString(), + }, + { + id: '6', + column1: '20', + column2: 'Fig', + column3: false, + numericColumn: 300, + dateColumn: new Date('2024-06-30T00:00:00.000Z').toISOString(), + }, + { + id: '7', + column1: '21', + column2: 'Grape', + column3: true, + numericColumn: 125, + dateColumn: new Date('2024-07-12T00:00:00.000Z').toISOString(), + }, + ]; + + public readonly appliedFilters = input< + SkyFilterBarFilterItem[] + >([]); + public readonly removeRowIds = model([]); + public readonly rowHighlightedId = model(); + public readonly selectedRowIds = model([]); + public readonly showFilteredGrid = input(false); + protected readonly showFilteredMultiselectGrid = input(false); + public readonly textFilterOperator = input(); + public readonly numberFilterOperator = input(); + public readonly booleanFilterOperator = input(); + + public readonly multiselect = input(); + public readonly height = input(); + public readonly pageSize = input(); + + public page = model(1); + public pageQueryParam = ''; + + protected readonly showAllColumns = input(true); + protected readonly showAllGrids = input(true); + protected readonly showCol3 = input(true); + protected readonly showCol3HeaderText = input(true); + + readonly #cdr = inject(ChangeDetectorRef); + + public selectAll(): void { + this.selectedRowIds.set( + (this.dataForSimpleGridWithMultiselect ?? []).map( + (item) => item.myId as string, + ), + ); + this.#cdr.markForCheck(); + } + + public clearAll(): void { + this.selectedRowIds.set([]); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public cancelRowDelete(_cancelArgs: SkyDataGridRowDeleteCancelArgs): void { + // noop + } + + public deleteItem(id: string): void { + this.removeRowIds.update((removeRowIds) => [id, ...removeRowIds]); + } + + public finishRowDelete(confirmArgs: SkyDataGridRowDeleteConfirmArgs): void { + this.dataForRowDeleteGrid = (this.dataForRowDeleteGrid ?? []).filter( + (data: RowModel) => data.id !== confirmArgs.id, + ); + } + + public selectRow(): void { + this.selectedRowIds.set(['101', '103', '105']); + } +} diff --git a/libs/components/data-grid/testing/src/modules/data-grid/data-grid-harness.ts b/libs/components/data-grid/testing/src/modules/data-grid/data-grid-harness.ts index a28be985e6..4b979c16f6 100644 --- a/libs/components/data-grid/testing/src/modules/data-grid/data-grid-harness.ts +++ b/libs/components/data-grid/testing/src/modules/data-grid/data-grid-harness.ts @@ -12,7 +12,7 @@ export class SkyDataGridHarness extends SkyQueryableComponentHarness { /** * @internal */ - public static hostSelector = 'sky-data-grid'; + public static hostSelector = 'sky-data-grid, sky-data-grid-lite'; /** * Gets a `HarnessPredicate` that can be used to search for a diff --git a/libs/components/data-manager/src/lib/modules/data-manager/data-manager-host/data-manager-host-controller.directive.ts b/libs/components/data-manager/src/lib/modules/data-manager/data-manager-host/data-manager-host-controller.directive.ts index a1074dbef1..755c6bacf8 100644 --- a/libs/components/data-manager/src/lib/modules/data-manager/data-manager-host/data-manager-host-controller.directive.ts +++ b/libs/components/data-manager/src/lib/modules/data-manager/data-manager-host/data-manager-host-controller.directive.ts @@ -12,7 +12,6 @@ import { SkyDataHost, SkyDataHostService } from '@skyux/lists'; import { filter, map, switchMap } from 'rxjs'; -import { SkyDataManagerFilterControllerDirective } from '../data-manager-filters/data-manager-filter-controller.directive'; import { SkyDataManagerService } from '../data-manager.service'; import { SkyDataManagerSortOption } from '../models/data-manager-sort-option'; import { SkyDataManagerState } from '../models/data-manager-state'; @@ -21,12 +20,12 @@ import { SkyDataViewState } from '../models/data-view-state'; import { SkyDataManagerHostService } from './data-manager-host.service'; /** - * A directive applied to a data-displaying component (like `sky-data-grid`) that enables integration with a data manager. - * This directive synchronizes the component's data state with the data manager's state, including displayed columns, - * sort order, current page, and selected rows. + * A directive applied to `sky-data-view` when data-displaying component (like `sky-data-grid`) that enables integration + * with a data manager. This directive synchronizes the component's data state with the data manager's state, including + * displayed columns, sort order, current page, and selected rows. */ @Directive({ - selector: '[skyDataManagerHostController]', + selector: 'sky-data-view[skyDataManagerHostController]', providers: [ SkyDataManagerHostService, { @@ -34,7 +33,6 @@ import { SkyDataManagerHostService } from './data-manager-host.service'; useExisting: SkyDataManagerHostService, }, ], - hostDirectives: [SkyDataManagerFilterControllerDirective], }) export class SkyDataManagerHostControllerDirective { /** From 11149bca2d3aa60d1b380876f97a462318021242 Mon Sep 17 00:00:00 2001 From: John White <750350+johnhwhite@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:56:54 -0500 Subject: [PATCH 2/2] =?UTF-8?q?Export=20directive=20as=20=CE=BB1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- libs/components/data-grid/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/components/data-grid/src/index.ts b/libs/components/data-grid/src/index.ts index a78d338431..2f543c2aba 100644 --- a/libs/components/data-grid/src/index.ts +++ b/libs/components/data-grid/src/index.ts @@ -12,3 +12,4 @@ export { SkyDataGridSort } from './lib/modules/types/data-grid-sort'; export { SkyDataGridPageRequest } from './lib/modules/types/data-grid-page-request'; export { SkyDataGridRowDeleteCancelArgs } from './lib/modules/types/data-grid-row-delete-cancel-args'; export { SkyDataGridRowDeleteConfirmArgs } from './lib/modules/types/data-grid-row-delete-confirm-args'; +export { SkyDataGridDirective as λ1 } from './lib/modules/data-grid/data-grid.directive';
Selected rows: {{ selectedRowIds() | json }}