diff --git a/libs/components/grids/package.json b/libs/components/grids/package.json index 0aa237b7a9..525aa8f5c2 100644 --- a/libs/components/grids/package.json +++ b/libs/components/grids/package.json @@ -16,6 +16,7 @@ }, "homepage": "https://github.com/blackbaud/skyux#readme", "peerDependencies": { + "@angular/cdk": "^20.2.3", "@angular/common": "^20.3.0", "@angular/core": "^20.3.0", "@angular/forms": "^20.3.0", diff --git a/libs/components/grids/project.json b/libs/components/grids/project.json index 6073c160cf..6f86473ffb 100644 --- a/libs/components/grids/project.json +++ b/libs/components/grids/project.json @@ -4,6 +4,7 @@ "projectType": "library", "sourceRoot": "libs/components/grids/src", "prefix": "sky", + "tags": ["component", "npm"], "targets": { "build": { "executor": "@nx/angular:package", @@ -19,7 +20,21 @@ "tsConfig": "libs/components/grids/tsconfig.lib.json" } }, - "defaultConfiguration": "production" + "defaultConfiguration": "production", + "dependsOn": [ + "^build", + { + "projects": ["core"], + "target": "build" + } + ], + "inputs": [ + "buildInputs", + "^buildInputs", + "{workspaceRoot}/libs/components/grids/testing/src/**/*", + "!{workspaceRoot}/libs/components/grids/testing/src/**/*.spec.ts", + "!{workspaceRoot}/libs/components/grids/testing/src/**/fixtures/**/*" + ] }, "test": { "executor": "@angular-devkit/build-angular:karma", @@ -61,6 +76,5 @@ ] } } - }, - "tags": ["component", "npm"] + } } diff --git a/libs/components/grids/testing/eslint.config.js b/libs/components/grids/testing/eslint.config.js new file mode 100644 index 0000000000..b4c331fb6a --- /dev/null +++ b/libs/components/grids/testing/eslint.config.js @@ -0,0 +1,5 @@ +const prettier = require('eslint-config-prettier'); +const baseConfig = require('../../../../eslint-base.config'); +const overrides = require('../../../../eslint-overrides.config'); + +module.exports = [...baseConfig, ...overrides, prettier]; diff --git a/libs/components/grids/testing/karma.conf.js b/libs/components/grids/testing/karma.conf.js new file mode 100644 index 0000000000..a3e45fc4a4 --- /dev/null +++ b/libs/components/grids/testing/karma.conf.js @@ -0,0 +1,19 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +const { join } = require('path'); +const getBaseKarmaConfig = require('../../../../karma.conf'); + +module.exports = function (config) { + const baseConfig = getBaseKarmaConfig(); + config.set({ + ...baseConfig, + coverageReporter: { + ...baseConfig.coverageReporter, + dir: join( + __dirname, + '../../../../coverage/libs/components/grids/testing', + ), + }, + }); +}; diff --git a/libs/components/grids/testing/ng-package.json b/libs/components/grids/testing/ng-package.json new file mode 100644 index 0000000000..fbafcc4448 --- /dev/null +++ b/libs/components/grids/testing/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/libs/components/grids/testing/project.json b/libs/components/grids/testing/project.json new file mode 100644 index 0000000000..402803673c --- /dev/null +++ b/libs/components/grids/testing/project.json @@ -0,0 +1,47 @@ +{ + "name": "grids-testing", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/components/grids/testing/src", + "prefix": "sky", + "tags": ["testing"], + "targets": { + "test": { + "executor": "@angular-devkit/build-angular:karma", + "options": { + "tsConfig": "libs/components/grids/testing/tsconfig.spec.json", + "karmaConfig": "libs/components/grids/testing/karma.conf.js", + "codeCoverage": true, + "codeCoverageExclude": ["**/fixtures/**"], + "styles": [ + "libs/components/theme/src/lib/styles/sky.scss", + "libs/components/theme/src/lib/styles/themes/modern/styles.scss" + ], + "polyfills": [ + "zone.js", + "zone.js/testing", + "libs/components/packages/src/polyfills.js" + ], + "inlineStyleLanguage": "scss", + "stylePreprocessorOptions": { + "includePaths": ["{workspaceRoot}"] + } + }, + "configurations": { + "ci": { + "browsers": "ChromeHeadlessNoSandbox", + "codeCoverage": true, + "progress": false, + "sourceMap": true, + "watch": false + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "options": { + "lintFilePatterns": ["{projectRoot}/src/**/*.ts"] + } + } + } +} diff --git a/libs/components/grids/testing/src/modules/grid/grid-column-harness-filters.ts b/libs/components/grids/testing/src/modules/grid/grid-column-harness-filters.ts new file mode 100644 index 0000000000..bb8c68c2ed --- /dev/null +++ b/libs/components/grids/testing/src/modules/grid/grid-column-harness-filters.ts @@ -0,0 +1,16 @@ +import { BaseHarnessFilters } from '@angular/cdk/testing'; + +/** + * A set of criteria that can be used to filter a list of `SkyGridColumnHarness` instances. + */ +export interface SkyGridColumnHarnessFilters extends BaseHarnessFilters { + /** + * Only find instances whose column ID matches the given value. + */ + columnId?: string | RegExp; + + /** + * Only find instances whose heading text matches the given value. + */ + headingText?: string | RegExp; +} diff --git a/libs/components/grids/testing/src/modules/grid/grid-column-harness.ts b/libs/components/grids/testing/src/modules/grid/grid-column-harness.ts new file mode 100644 index 0000000000..895202473c --- /dev/null +++ b/libs/components/grids/testing/src/modules/grid/grid-column-harness.ts @@ -0,0 +1,77 @@ +import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing'; + +import { SkyGridColumnHarnessFilters } from './grid-column-harness-filters'; + +/** + * Harness for interacting with a grid column header in tests. + */ +export class SkyGridColumnHarness extends ComponentHarness { + /** + * @internal + */ + public static hostSelector = 'th.sky-grid-heading'; + + #getHeaderText = this.locatorFor('.sky-grid-header-text'); + + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyGridColumnHarness` that meets certain criteria. + */ + public static with( + filters: SkyGridColumnHarnessFilters, + ): HarnessPredicate { + return new HarnessPredicate(SkyGridColumnHarness, filters) + .addOption('columnId', filters.columnId, async (harness, columnId) => { + const id = await harness.getColumnId(); + return await HarnessPredicate.stringMatches(id, columnId); + }) + .addOption( + 'headingText', + filters.headingText, + async (harness, headingText) => { + const text = await harness.getHeadingText(); + return await HarnessPredicate.stringMatches(text, headingText); + }, + ); + } + + /** + * Gets the column ID. + */ + public async getColumnId(): Promise { + return await (await this.host()).getAttribute('sky-cmp-id'); + } + + /** + * Gets the heading text of the column. + */ + public async getHeadingText(): Promise { + return (await (await this.#getHeaderText()).text()).trim(); + } + + /** + * Gets the sort direction of the column. + * Returns 'ascending', 'descending', 'none', or null if not sortable. + */ + public async getSortDirection(): Promise { + return await (await this.host()).getAttribute('aria-sort'); + } + + /** + * Whether the column is sortable. + */ + public async isSortable(): Promise { + const tabIndex = await (await this.host()).getAttribute('tabindex'); + return tabIndex === '0'; + } + + /** + * Clicks the column header to sort by this column. + */ + public async sort(): Promise { + if (!(await this.isSortable())) { + throw new Error('Cannot sort by this column because it is not sortable.'); + } + await (await this.host()).click(); + } +} diff --git a/libs/components/grids/testing/src/modules/grid/grid-harness-filters.ts b/libs/components/grids/testing/src/modules/grid/grid-harness-filters.ts new file mode 100644 index 0000000000..34fe93c7b1 --- /dev/null +++ b/libs/components/grids/testing/src/modules/grid/grid-harness-filters.ts @@ -0,0 +1,7 @@ +import { SkyHarnessFilters } from '@skyux/core/testing'; + +/** + * A set of criteria that can be used to filter a list of `SkyGridHarness` instances. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type +export interface SkyGridHarnessFilters extends SkyHarnessFilters {} diff --git a/libs/components/grids/testing/src/modules/grid/grid-harness.spec.ts b/libs/components/grids/testing/src/modules/grid/grid-harness.spec.ts new file mode 100644 index 0000000000..64a3940785 --- /dev/null +++ b/libs/components/grids/testing/src/modules/grid/grid-harness.spec.ts @@ -0,0 +1,481 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SkyGridModule } from '@skyux/grids'; + +import { SkyGridColumnHarness } from './grid-column-harness'; +import { SkyGridHarness } from './grid-harness'; +import { SkyGridRowHarness } from './grid-row-harness'; + +//#region Test component +@Component({ + selector: 'sky-grid-test', + template: ` + + + + + + + + + + + `, + standalone: false, +}) +class TestComponent { + public data = [ + { id: '1', name: 'John Doe', email: 'john@example.com', amount: 100 }, + { id: '2', name: 'Jane Smith', email: 'jane@example.com', amount: 200 }, + { id: '3', name: 'Bob Johnson', email: 'bob@example.com', amount: 300 }, + ]; + + public enableMultiselect = false; + public fit = 'width'; + public hasToolbar = false; + public rowHighlightedId: string | undefined; + public selectedRowIds: string[] = []; +} +//#endregion Test component + +describe('Grid harness', () => { + async function setupTest(options: { dataSkyId?: string } = {}): Promise<{ + gridHarness: SkyGridHarness; + fixture: ComponentFixture; + loader: HarnessLoader; + }> { + await TestBed.configureTestingModule({ + declarations: [TestComponent], + imports: [SkyGridModule, NoopAnimationsModule], + }).compileComponents(); + + const fixture = TestBed.createComponent(TestComponent); + const loader = TestbedHarnessEnvironment.loader(fixture); + + let gridHarness: SkyGridHarness; + + if (options.dataSkyId) { + gridHarness = await loader.getHarness( + SkyGridHarness.with({ + dataSkyId: options.dataSkyId, + }), + ); + } else { + gridHarness = await loader.getHarness(SkyGridHarness); + } + + return { gridHarness, fixture, loader }; + } + + it('should get a grid by its data-sky-id property', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'grid-2' }); + const columns = await gridHarness.getColumns(); + expect(columns.length).toBe(3); + }); + + describe('columns', () => { + it('should get all columns', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const columns = await gridHarness.getColumns(); + expect(columns.length).toBe(3); + expect(columns[0] instanceof SkyGridColumnHarness).toBeTrue(); + }); + + it('should get column count', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const count = await gridHarness.getColumnCount(); + expect(count).toBe(3); + }); + + it('should get column heading texts', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const headings = await gridHarness.getColumnHeadingTexts(); + expect(headings).toEqual(['Name', 'Email', 'Amount']); + }); + + it('should get column IDs', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const ids = await gridHarness.getColumnIds(); + expect(ids).toEqual(['name', 'email', 'amount']); + }); + + it('should get columns by filter', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const columns = await gridHarness.getColumnsByFilter({ + columnId: 'name', + }); + expect(columns.length).toBe(1); + await expectAsync(columns[0].getColumnId()).toBeResolvedTo('name'); + }); + + it('should get a specific column by filter', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const column = await gridHarness.getColumn({ headingText: 'Email' }); + await expectAsync(column.getHeadingText()).toBeResolvedTo('Email'); + }); + + it('should return empty array when no columns match filter', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const columns = await gridHarness.getColumnsByFilter({ + columnId: 'nonexistent', + }); + expect(columns).toEqual([]); + }); + }); + + describe('column harness', () => { + it('should get column ID', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const columns = await gridHarness.getColumns(); + await expectAsync(columns[0].getColumnId()).toBeResolvedTo('name'); + }); + + it('should get heading text', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const columns = await gridHarness.getColumns(); + await expectAsync(columns[0].getHeadingText()).toBeResolvedTo('Name'); + }); + + it('should check if column is sortable', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const columns = await gridHarness.getColumns(); + await expectAsync(columns[0].isSortable()).toBeResolvedTo(true); + await expectAsync(columns[2].isSortable()).toBeResolvedTo(false); + }); + + it('should get sort direction', async () => { + const { gridHarness, fixture } = await setupTest({ + dataSkyId: 'test-grid', + }); + fixture.detectChanges(); + const columns = await gridHarness.getColumns(); + const sortDirection = await columns[0].getSortDirection(); + expect(sortDirection === 'none' || sortDirection === null).toBeTrue(); + }); + + it('should sort by column', async () => { + const { gridHarness, fixture } = await setupTest({ + dataSkyId: 'test-grid', + }); + fixture.detectChanges(); + const column = await gridHarness.getColumn({ columnId: 'name' }); + await column.sort(); + fixture.detectChanges(); + const sortDirection = await column.getSortDirection(); + expect(sortDirection).toBe('descending'); + }); + + it('should throw error when sorting non-sortable column', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const column = await gridHarness.getColumn({ columnId: 'amount' }); + await expectAsync(column.sort()).toBeRejectedWithError( + 'Cannot sort by this column because it is not sortable.', + ); + }); + + it('should sort grid by column using grid harness method', async () => { + const { gridHarness, fixture } = await setupTest({ + dataSkyId: 'test-grid', + }); + fixture.detectChanges(); + await gridHarness.sortByColumn({ columnId: 'email' }); + fixture.detectChanges(); + const column = await gridHarness.getColumn({ columnId: 'email' }); + const sortDirection = await column.getSortDirection(); + expect(sortDirection).toBe('descending'); + }); + }); + + describe('rows', () => { + it('should get all rows', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const rows = await gridHarness.getRows(); + expect(rows.length).toBe(3); + expect(rows[0] instanceof SkyGridRowHarness).toBeTrue(); + }); + + it('should get row count', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const count = await gridHarness.getRowCount(); + expect(count).toBe(3); + }); + + it('should get rows by filter', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const rows = await gridHarness.getRowsByFilter({ rowId: '1' }); + expect(rows.length).toBe(1); + await expectAsync(rows[0].getRowId()).toBeResolvedTo('1'); + }); + + it('should get a specific row by filter', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const row = await gridHarness.getRow({ rowId: '2' }); + await expectAsync(row.getRowId()).toBeResolvedTo('2'); + }); + + it('should return empty array when no rows match filter', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const rows = await gridHarness.getRowsByFilter({ rowId: 'nonexistent' }); + expect(rows).toEqual([]); + }); + }); + + describe('row harness', () => { + it('should get row ID', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const rows = await gridHarness.getRows(); + await expectAsync(rows[0].getRowId()).toBeResolvedTo('1'); + }); + + it('should get cell texts', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const rows = await gridHarness.getRows(); + const cellTexts = await rows[0].getCellTexts(); + expect(cellTexts).toEqual(['John Doe', 'john@example.com', '100']); + }); + + it('should get specific cell text by index', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const rows = await gridHarness.getRows(); + await expectAsync(rows[0].getCellText(0)).toBeResolvedTo('John Doe'); + await expectAsync(rows[0].getCellText(1)).toBeResolvedTo( + 'john@example.com', + ); + }); + + it('should throw error for out of bounds cell index', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const rows = await gridHarness.getRows(); + await expectAsync(rows[0].getCellText(10)).toBeRejectedWithError( + 'Cell index 10 is out of bounds. Row has 3 cells.', + ); + await expectAsync(rows[0].getCellText(-1)).toBeRejectedWithError( + 'Cell index -1 is out of bounds. Row has 3 cells.', + ); + }); + + it('should click row', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const rows = await gridHarness.getRows(); + await rows[0].click(); + }); + }); + + describe('row highlighting', () => { + it('should check if row is highlighted', async () => { + const { gridHarness, fixture } = await setupTest({ + dataSkyId: 'test-grid', + }); + const rows = await gridHarness.getRows(); + await expectAsync(rows[0].isHighlighted()).toBeResolvedTo(false); + + fixture.componentInstance.rowHighlightedId = '1'; + fixture.detectChanges(); + + await expectAsync(rows[0].isHighlighted()).toBeResolvedTo(true); + await expectAsync(rows[1].isHighlighted()).toBeResolvedTo(false); + }); + + it('should get highlighted row', async () => { + const { gridHarness, fixture } = await setupTest({ + dataSkyId: 'test-grid', + }); + + let highlightedRow = await gridHarness.getHighlightedRow(); + expect(highlightedRow).toBeNull(); + + fixture.componentInstance.rowHighlightedId = '2'; + fixture.detectChanges(); + + highlightedRow = await gridHarness.getHighlightedRow(); + expect(highlightedRow).not.toBeNull(); + if (highlightedRow) { + await expectAsync(highlightedRow.getRowId()).toBeResolvedTo('2'); + } + }); + }); + + describe('multiselect', () => { + it('should check if multiselect is enabled', async () => { + const { gridHarness, fixture } = await setupTest({ + dataSkyId: 'test-grid', + }); + await expectAsync(gridHarness.hasMultiselect()).toBeResolvedTo(false); + + fixture.componentInstance.enableMultiselect = true; + fixture.detectChanges(); + + await expectAsync(gridHarness.hasMultiselect()).toBeResolvedTo(true); + }); + + it('should check if row is selectable', async () => { + const { gridHarness, fixture } = await setupTest({ + dataSkyId: 'test-grid', + }); + const rows = await gridHarness.getRows(); + await expectAsync(rows[0].isSelectable()).toBeResolvedTo(false); + + fixture.componentInstance.enableMultiselect = true; + fixture.detectChanges(); + + const rowsWithMultiselect = await gridHarness.getRows(); + await expectAsync(rowsWithMultiselect[0].isSelectable()).toBeResolvedTo( + true, + ); + }); + + it('should check if row is selected', async () => { + const { gridHarness, fixture } = await setupTest({ + dataSkyId: 'test-grid', + }); + fixture.componentInstance.enableMultiselect = true; + fixture.detectChanges(); + + const rows = await gridHarness.getRows(); + await expectAsync(rows[0].isSelected()).toBeResolvedTo(false); + }); + + it('should select and deselect row', async () => { + const { gridHarness, fixture } = await setupTest({ + dataSkyId: 'test-grid', + }); + fixture.componentInstance.enableMultiselect = true; + fixture.detectChanges(); + + const rows = await gridHarness.getRows(); + await expectAsync(rows[0].isSelected()).toBeResolvedTo(false); + + await rows[0].select(); + fixture.detectChanges(); + await expectAsync(rows[0].isSelected()).toBeResolvedTo(true); + + await rows[0].deselect(); + fixture.detectChanges(); + await expectAsync(rows[0].isSelected()).toBeResolvedTo(false); + }); + + it('should not re-select already selected row', async () => { + const { gridHarness, fixture } = await setupTest({ + dataSkyId: 'test-grid', + }); + fixture.componentInstance.enableMultiselect = true; + fixture.componentInstance.selectedRowIds = ['1']; + fixture.detectChanges(); + + const rows = await gridHarness.getRows(); + await expectAsync(rows[0].isSelected()).toBeResolvedTo(true); + + await rows[0].select(); + fixture.detectChanges(); + await expectAsync(rows[0].isSelected()).toBeResolvedTo(true); + }); + + it('should not re-deselect already deselected row', async () => { + const { gridHarness, fixture } = await setupTest({ + dataSkyId: 'test-grid', + }); + fixture.componentInstance.enableMultiselect = true; + fixture.detectChanges(); + + const rows = await gridHarness.getRows(); + await expectAsync(rows[0].isSelected()).toBeResolvedTo(false); + + await rows[0].deselect(); + fixture.detectChanges(); + await expectAsync(rows[0].isSelected()).toBeResolvedTo(false); + }); + + it('should throw error when selecting non-selectable row', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const rows = await gridHarness.getRows(); + await expectAsync(rows[0].select()).toBeRejectedWithError( + 'Cannot select this row because multiselect is not enabled.', + ); + }); + + it('should throw error when deselecting non-selectable row', async () => { + const { gridHarness } = await setupTest({ dataSkyId: 'test-grid' }); + const rows = await gridHarness.getRows(); + await expectAsync(rows[0].deselect()).toBeRejectedWithError( + 'Cannot deselect this row because multiselect is not enabled.', + ); + }); + + it('should get selected rows', async () => { + const { gridHarness, fixture } = await setupTest({ + dataSkyId: 'test-grid', + }); + fixture.componentInstance.enableMultiselect = true; + fixture.componentInstance.selectedRowIds = ['1', '3']; + fixture.detectChanges(); + + const selectedRows = await gridHarness.getSelectedRows(); + expect(selectedRows.length).toBe(2); + await expectAsync(selectedRows[0].getRowId()).toBeResolvedTo('1'); + await expectAsync(selectedRows[1].getRowId()).toBeResolvedTo('3'); + }); + + it('should return empty array when no rows are selected', async () => { + const { gridHarness, fixture } = await setupTest({ + dataSkyId: 'test-grid', + }); + fixture.componentInstance.enableMultiselect = true; + fixture.detectChanges(); + + const selectedRows = await gridHarness.getSelectedRows(); + expect(selectedRows).toEqual([]); + }); + }); + + describe('grid properties', () => { + it('should get fit mode', async () => { + const { gridHarness, fixture } = await setupTest({ + dataSkyId: 'test-grid', + }); + await expectAsync(gridHarness.getFitMode()).toBeResolvedTo('width'); + + fixture.componentInstance.fit = 'scroll'; + fixture.detectChanges(); + + await expectAsync(gridHarness.getFitMode()).toBeResolvedTo('scroll'); + }); + + it('should check if grid has toolbar', async () => { + const { gridHarness, fixture } = await setupTest({ + dataSkyId: 'test-grid', + }); + await expectAsync(gridHarness.hasToolbar()).toBeResolvedTo(false); + + fixture.componentInstance.hasToolbar = true; + fixture.detectChanges(); + + await expectAsync(gridHarness.hasToolbar()).toBeResolvedTo(true); + }); + }); +}); diff --git a/libs/components/grids/testing/src/modules/grid/grid-harness.ts b/libs/components/grids/testing/src/modules/grid/grid-harness.ts new file mode 100644 index 0000000000..9d6dc70463 --- /dev/null +++ b/libs/components/grids/testing/src/modules/grid/grid-harness.ts @@ -0,0 +1,185 @@ +import { HarnessPredicate } from '@angular/cdk/testing'; +import { SkyComponentHarness } from '@skyux/core/testing'; + +import { SkyGridColumnHarness } from './grid-column-harness'; +import { SkyGridColumnHarnessFilters } from './grid-column-harness-filters'; +import { SkyGridHarnessFilters } from './grid-harness-filters'; +import { SkyGridRowHarness } from './grid-row-harness'; +import { SkyGridRowHarnessFilters } from './grid-row-harness-filters'; + +/** + * Harness for interacting with a grid component in tests. + */ +export class SkyGridHarness extends SkyComponentHarness { + /** + * @internal + */ + public static hostSelector = 'sky-grid'; + + #getTable = this.locatorFor('.sky-grid-table'); + + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyGridHarness` that meets certain criteria. + */ + public static with( + filters: SkyGridHarnessFilters, + ): HarnessPredicate { + return SkyGridHarness.getDataSkyIdPredicate(filters); + } + + /** + * Gets all column harnesses. + */ + public async getColumns(): Promise { + return await this.locatorForAll(SkyGridColumnHarness)(); + } + + /** + * Gets column harnesses matching the given filters. + */ + public async getColumnsByFilter( + filters: SkyGridColumnHarnessFilters, + ): Promise { + return await this.locatorForAll(SkyGridColumnHarness.with(filters))(); + } + + /** + * Gets a specific column harness by filter. + */ + public async getColumn( + filters: SkyGridColumnHarnessFilters, + ): Promise { + return await this.locatorFor(SkyGridColumnHarness.with(filters))(); + } + + /** + * Gets all row harnesses. + */ + public async getRows(): Promise { + return await this.locatorForAll(SkyGridRowHarness)(); + } + + /** + * Gets row harnesses matching the given filters. + */ + public async getRowsByFilter( + filters: SkyGridRowHarnessFilters, + ): Promise { + return await this.locatorForAll(SkyGridRowHarness.with(filters))(); + } + + /** + * Gets a specific row harness by filter. + */ + public async getRow( + filters: SkyGridRowHarnessFilters, + ): Promise { + return await this.locatorFor(SkyGridRowHarness.with(filters))(); + } + + /** + * Gets the number of columns in the grid. + */ + public async getColumnCount(): Promise { + const columns = await this.getColumns(); + return columns.length; + } + + /** + * Gets the number of rows in the grid. + */ + public async getRowCount(): Promise { + const rows = await this.getRows(); + return rows.length; + } + + /** + * Whether the grid has multiselect enabled. + */ + public async hasMultiselect(): Promise { + const multiselectCells = await this.locatorForAll( + '.sky-grid-multiselect-cell', + )(); + return multiselectCells.length > 0; + } + + /** + * Gets all selected rows (for multiselect grids). + */ + public async getSelectedRows(): Promise { + const rows = await this.getRows(); + const selectedRows: SkyGridRowHarness[] = []; + for (const row of rows) { + if (await row.isSelected()) { + selectedRows.push(row); + } + } + return selectedRows; + } + + /** + * Gets the highlighted row, or null if no row is highlighted. + */ + public async getHighlightedRow(): Promise { + const rows = await this.getRows(); + for (const row of rows) { + if (await row.isHighlighted()) { + return row; + } + } + return null; + } + + /** + * Gets the fit mode of the grid ('width' or 'scroll'). + */ + public async getFitMode(): Promise { + const table = await this.#getTable(); + const hasFitClass = await table.hasClass('sky-grid-fit'); + return hasFitClass ? 'width' : 'scroll'; + } + + /** + * Whether the grid has a toolbar. + */ + public async hasToolbar(): Promise { + const table = await this.#getTable(); + return await table.hasClass('sky-grid-has-toolbar'); + } + + /** + * Gets all column heading texts. + */ + public async getColumnHeadingTexts(): Promise { + const columns = await this.getColumns(); + const texts: string[] = []; + for (const column of columns) { + texts.push(await column.getHeadingText()); + } + return texts; + } + + /** + * Gets all column IDs. + */ + public async getColumnIds(): Promise<(string | null)[]> { + const columns = await this.getColumns(); + const ids: (string | null)[] = []; + for (const column of columns) { + ids.push(await column.getColumnId()); + } + return ids; + } + + /** + * Sorts the grid by the specified column. + * @param filters The filter criteria to find the column. + */ + public async sortByColumn( + filters: SkyGridColumnHarnessFilters, + ): Promise { + const column = await this.getColumn(filters); + await column.sort(); + } +} diff --git a/libs/components/grids/testing/src/modules/grid/grid-row-harness-filters.ts b/libs/components/grids/testing/src/modules/grid/grid-row-harness-filters.ts new file mode 100644 index 0000000000..d67b980357 --- /dev/null +++ b/libs/components/grids/testing/src/modules/grid/grid-row-harness-filters.ts @@ -0,0 +1,11 @@ +import { BaseHarnessFilters } from '@angular/cdk/testing'; + +/** + * A set of criteria that can be used to filter a list of `SkyGridRowHarness` instances. + */ +export interface SkyGridRowHarnessFilters extends BaseHarnessFilters { + /** + * Only find instances whose row ID matches the given value. + */ + rowId?: string | RegExp; +} diff --git a/libs/components/grids/testing/src/modules/grid/grid-row-harness.ts b/libs/components/grids/testing/src/modules/grid/grid-row-harness.ts new file mode 100644 index 0000000000..ebfd4a58b2 --- /dev/null +++ b/libs/components/grids/testing/src/modules/grid/grid-row-harness.ts @@ -0,0 +1,126 @@ +import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing'; + +import { SkyGridRowHarnessFilters } from './grid-row-harness-filters'; + +/** + * Harness for interacting with a grid row in tests. + */ +export class SkyGridRowHarness extends ComponentHarness { + /** + * @internal + */ + public static hostSelector = 'tr.sky-grid-row'; + + #getCheckbox = this.locatorForOptional('sky-checkbox input[type="checkbox"]'); + #getCells = this.locatorForAll('td.sky-grid-cell'); + + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyGridRowHarness` that meets certain criteria. + */ + public static with( + filters: SkyGridRowHarnessFilters, + ): HarnessPredicate { + return new HarnessPredicate(SkyGridRowHarness, filters).addOption( + 'rowId', + filters.rowId, + async (harness, rowId) => { + const id = await harness.getRowId(); + return await HarnessPredicate.stringMatches(id, rowId); + }, + ); + } + + /** + * Gets the row ID. + */ + public async getRowId(): Promise { + return await (await this.host()).getAttribute('sky-cmp-id'); + } + + /** + * Gets the text content of all cells in the row. + */ + public async getCellTexts(): Promise { + const cells = await this.#getCells(); + const texts: string[] = []; + for (const cell of cells) { + texts.push((await cell.text()).trim()); + } + return texts; + } + + /** + * Gets the text content of a specific cell by index. + */ + public async getCellText(index: number): Promise { + const cells = await this.#getCells(); + if (index < 0 || index >= cells.length) { + throw new Error( + `Cell index ${index} is out of bounds. Row has ${cells.length} cells.`, + ); + } + return (await cells[index].text()).trim(); + } + + /** + * Whether the row is highlighted. + */ + public async isHighlighted(): Promise { + const ariaCurrent = await (await this.host()).getAttribute('aria-current'); + return ariaCurrent === 'true'; + } + + /** + * Whether the row is selected (for multiselect grids). + */ + public async isSelected(): Promise { + const host = await this.host(); + return await host.hasClass('sky-grid-multiselect-selected-row'); + } + + /** + * Whether the row has multiselect enabled. + */ + public async isSelectable(): Promise { + const checkbox = await this.#getCheckbox(); + return checkbox !== null; + } + + /** + * Selects the row (for multiselect grids). + */ + public async select(): Promise { + if (!(await this.isSelectable())) { + throw new Error( + 'Cannot select this row because multiselect is not enabled.', + ); + } + if (await this.isSelected()) { + return; + } + await (await this.host()).click(); + } + + /** + * Deselects the row (for multiselect grids). + */ + public async deselect(): Promise { + if (!(await this.isSelectable())) { + throw new Error( + 'Cannot deselect this row because multiselect is not enabled.', + ); + } + if (!(await this.isSelected())) { + return; + } + await (await this.host()).click(); + } + + /** + * Clicks the row. + */ + public async click(): Promise { + await (await this.host()).click(); + } +} diff --git a/libs/components/grids/testing/src/public-api.ts b/libs/components/grids/testing/src/public-api.ts new file mode 100644 index 0000000000..dd7e774f53 --- /dev/null +++ b/libs/components/grids/testing/src/public-api.ts @@ -0,0 +1,8 @@ +export { SkyGridColumnHarness } from './modules/grid/grid-column-harness'; +export { SkyGridColumnHarnessFilters } from './modules/grid/grid-column-harness-filters'; + +export { SkyGridHarness } from './modules/grid/grid-harness'; +export { SkyGridHarnessFilters } from './modules/grid/grid-harness-filters'; + +export { SkyGridRowHarness } from './modules/grid/grid-row-harness'; +export { SkyGridRowHarnessFilters } from './modules/grid/grid-row-harness-filters'; diff --git a/libs/components/grids/testing/tsconfig.spec.json b/libs/components/grids/testing/tsconfig.spec.json new file mode 100644 index 0000000000..aad2a9f430 --- /dev/null +++ b/libs/components/grids/testing/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.spec.json", + "compilerOptions": { + "outDir": "../../../../out-tsc/spec", + "types": ["jasmine", "node"] + }, + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index d382b60fcd..7606dcc01f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -89,6 +89,9 @@ "libs/components/forms/testing/src/public-api.ts" ], "@skyux/grids": ["libs/components/grids/src/index.ts"], + "@skyux/grids/testing": [ + "libs/components/grids/testing/src/public-api.ts" + ], "@skyux/help-inline": ["libs/components/help-inline/src/index.ts"], "@skyux/help-inline/testing": [ "libs/components/help-inline/testing/src/public-api.ts"