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-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..b36af81156 --- /dev/null +++ b/libs/components/grids/testing/src/modules/grid/grid-harness.spec.ts @@ -0,0 +1,188 @@ +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 { expect } from '@skyux-sdk/testing'; +import { SkyGridModule } from '@skyux/grids'; + +import { SkyGridHarness } from './grid-harness'; + +//#region Test Component +@Component({ + selector: 'sky-grid-test', + template: ` + + + + + + + + + `, + standalone: false, +}) +class TestComponent { + public data = [ + { id: '1', name: 'John', age: 30 }, + { id: '2', name: 'Jane', age: 25 }, + { id: '3', name: 'Bob', age: 35 }, + ]; + + public otherData = [{ id: '4', name: 'Alice', age: 28 }]; + + public enableMultiselect = false; + public selectedRowIds: string[] = []; + public rowHighlightedId: string | undefined; +} +//#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], + }).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); + } + + fixture.detectChanges(); + + return { gridHarness, fixture, loader }; + } + + it('should get the grid from its data-sky-id', async () => { + const { gridHarness } = await setupTest({ + dataSkyId: 'other-grid', + }); + const rowCount = await gridHarness.getRowCount(); + expect(rowCount).toBe(1); + }); + + it('should get the column count', async () => { + const { gridHarness } = await setupTest(); + const columnCount = await gridHarness.getColumnCount(); + expect(columnCount).toBe(2); + }); + + it('should get the column headings', async () => { + const { gridHarness } = await setupTest(); + const headings = await gridHarness.getColumnHeadings(); + expect(headings.length).toBe(2); + }); + + it('should get the row count', async () => { + const { gridHarness } = await setupTest(); + const rowCount = await gridHarness.getRowCount(); + expect(rowCount).toBe(3); + }); + + it('should get cell text', async () => { + const { gridHarness } = await setupTest(); + const cellText = await gridHarness.getCellText(0, 0); + expect(cellText).toBe('John'); + }); + + it('should throw error for invalid row index when getting cell text', async () => { + const { gridHarness } = await setupTest(); + await expectAsync(gridHarness.getCellText(10, 0)).toBeRejectedWithError( + 'Row index 10 is out of bounds. Grid has 3 rows.', + ); + }); + + it('should throw error for invalid column index when getting cell text', async () => { + const { gridHarness } = await setupTest(); + await expectAsync(gridHarness.getCellText(0, 10)).toBeRejectedWithError( + 'Column index 10 is out of bounds. Row has 2 columns.', + ); + }); + + it('should check if multiselect is enabled', async () => { + const { gridHarness, fixture } = await setupTest(); + expect(await gridHarness.hasMultiselect()).toBe(false); + + fixture.componentInstance.enableMultiselect = true; + fixture.detectChanges(); + + expect(await gridHarness.hasMultiselect()).toBe(true); + }); + + it('should check if row is selected', async () => { + const { gridHarness, fixture } = await setupTest(); + fixture.componentInstance.enableMultiselect = true; + fixture.componentInstance.selectedRowIds = ['1']; + fixture.detectChanges(); + + expect(await gridHarness.isRowSelected(0)).toBe(true); + expect(await gridHarness.isRowSelected(1)).toBe(false); + }); + + it('should throw error for invalid row index when checking selection', async () => { + const { gridHarness } = await setupTest(); + await expectAsync(gridHarness.isRowSelected(10)).toBeRejectedWithError( + 'Row index 10 is out of bounds. Grid has 3 rows.', + ); + }); + + it('should check if row is highlighted', async () => { + const { gridHarness, fixture } = await setupTest(); + fixture.componentInstance.rowHighlightedId = '2'; + fixture.detectChanges(); + + expect(await gridHarness.isRowHighlighted(0)).toBe(false); + expect(await gridHarness.isRowHighlighted(1)).toBe(true); + }); + + it('should throw error for invalid row index when checking highlight', async () => { + const { gridHarness } = await setupTest(); + await expectAsync(gridHarness.isRowHighlighted(10)).toBeRejectedWithError( + 'Row index 10 is out of bounds. Grid has 3 rows.', + ); + }); + + it('should click a row', async () => { + const { gridHarness } = await setupTest(); + await gridHarness.clickRow(0); + }); + + it('should throw error for invalid row index when clicking', async () => { + const { gridHarness } = await setupTest(); + await expectAsync(gridHarness.clickRow(10)).toBeRejectedWithError( + 'Row index 10 is out of bounds. Grid has 3 rows.', + ); + }); + + it('should click a column header', async () => { + const { gridHarness } = await setupTest(); + await gridHarness.clickColumnHeader(0); + }); + + it('should throw error for invalid column index when clicking header', async () => { + const { gridHarness } = await setupTest(); + await expectAsync(gridHarness.clickColumnHeader(10)).toBeRejectedWithError( + 'Column index 10 is out of bounds. Grid has 2 columns.', + ); + }); +}); 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..a1dbff1b51 --- /dev/null +++ b/libs/components/grids/testing/src/modules/grid/grid-harness.ts @@ -0,0 +1,154 @@ +import { HarnessPredicate } from '@angular/cdk/testing'; +import { SkyComponentHarness } from '@skyux/core/testing'; + +import { SkyGridHarnessFilters } from './grid-harness-filters'; + +/** + * Harness for interacting with a grid component in tests. + * @deprecated `SkyGridComponent` and its features are deprecated. Use the data grid instead. + */ +export class SkyGridHarness extends SkyComponentHarness { + /** + * @internal + */ + public static hostSelector = 'sky-grid'; + + #getRows = this.locatorForAll('tbody tr.sky-grid-row'); + #getHeaderCells = this.locatorForAll('th.sky-grid-heading'); + + /** + * 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 the number of columns in the grid. + */ + public async getColumnCount(): Promise { + const headers = await this.#getHeaderCells(); + return headers.length; + } + + /** + * Gets the column headings from the grid. + */ + public async getColumnHeadings(): Promise { + const headers = await this.#getHeaderCells(); + const headings: string[] = []; + for (const header of headers) { + const textElement = await header.getAttribute('sky-cmp-id'); + if (textElement) { + const headerText = await header.text(); + headings.push(headerText.trim()); + } + } + return headings; + } + + /** + * Gets the number of rows in the grid. + */ + public async getRowCount(): Promise { + const rows = await this.#getRows(); + return rows.length; + } + + /** + * Gets the text content of a specific cell. + * @param rowIndex The zero-based row index. + * @param columnIndex The zero-based column index. + */ + public async getCellText( + rowIndex: number, + columnIndex: number, + ): Promise { + const rows = await this.#getRows(); + if (rowIndex >= rows.length) { + throw new Error( + `Row index ${rowIndex} is out of bounds. Grid has ${rows.length} rows.`, + ); + } + const allCells = await this.locatorForAll( + 'tbody tr.sky-grid-row td.sky-grid-cell', + )(); + const columnCount = await this.getColumnCount(); + const cellIndex = rowIndex * columnCount + columnIndex; + if (columnIndex >= columnCount) { + throw new Error( + `Column index ${columnIndex} is out of bounds. Row has ${columnCount} columns.`, + ); + } + return (await allCells[cellIndex].text()).trim(); + } + + /** + * Checks if the grid has multiselect enabled. + */ + public async hasMultiselect(): Promise { + const multiselectCells = await this.locatorForAll( + '.sky-grid-multiselect-cell', + )(); + return multiselectCells.length > 0; + } + + /** + * Checks if a specific row is selected (for multiselect grids). + * @param rowIndex The zero-based row index. + */ + public async isRowSelected(rowIndex: number): Promise { + const rows = await this.#getRows(); + if (rowIndex >= rows.length) { + throw new Error( + `Row index ${rowIndex} is out of bounds. Grid has ${rows.length} rows.`, + ); + } + return await rows[rowIndex].hasClass('sky-grid-multiselect-selected-row'); + } + + /** + * Checks if a specific row is highlighted. + * @param rowIndex The zero-based row index. + */ + public async isRowHighlighted(rowIndex: number): Promise { + const rows = await this.#getRows(); + if (rowIndex >= rows.length) { + throw new Error( + `Row index ${rowIndex} is out of bounds. Grid has ${rows.length} rows.`, + ); + } + return await rows[rowIndex].hasClass('sky-grid-row-highlight'); + } + + /** + * Clicks on a specific row. + * @param rowIndex The zero-based row index. + */ + public async clickRow(rowIndex: number): Promise { + const rows = await this.#getRows(); + if (rowIndex >= rows.length) { + throw new Error( + `Row index ${rowIndex} is out of bounds. Grid has ${rows.length} rows.`, + ); + } + await rows[rowIndex].click(); + } + + /** + * Clicks on a column header to sort by that column. + * @param columnIndex The zero-based column index. + */ + public async clickColumnHeader(columnIndex: number): Promise { + const headers = await this.#getHeaderCells(); + if (columnIndex >= headers.length) { + throw new Error( + `Column index ${columnIndex} is out of bounds. Grid has ${headers.length} columns.`, + ); + } + await headers[columnIndex].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..f2267d638d --- /dev/null +++ b/libs/components/grids/testing/src/public-api.ts @@ -0,0 +1,2 @@ +export { SkyGridHarness } from './modules/grid/grid-harness'; +export { SkyGridHarnessFilters } from './modules/grid/grid-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/libs/components/list-builder/package.json b/libs/components/list-builder/package.json index 4d414c095d..fefefdc623 100644 --- a/libs/components/list-builder/package.json +++ b/libs/components/list-builder/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", "@skyux/core": "0.0.0-PLACEHOLDER", diff --git a/libs/components/list-builder/project.json b/libs/components/list-builder/project.json index b7d214052f..07b27d03ec 100644 --- a/libs/components/list-builder/project.json +++ b/libs/components/list-builder/project.json @@ -4,6 +4,7 @@ "projectType": "library", "sourceRoot": "libs/components/list-builder/src", "prefix": "sky", + "tags": ["component", "npm"], "targets": { "build": { "executor": "@nx/angular:package", @@ -19,7 +20,21 @@ "tsConfig": "libs/components/list-builder/tsconfig.lib.json" } }, - "defaultConfiguration": "production" + "defaultConfiguration": "production", + "dependsOn": [ + "^build", + { + "projects": ["core"], + "target": "build" + } + ], + "inputs": [ + "buildInputs", + "^buildInputs", + "{workspaceRoot}/libs/components/list-builder/testing/src/**/*", + "!{workspaceRoot}/libs/components/list-builder/testing/src/**/*.spec.ts", + "!{workspaceRoot}/libs/components/list-builder/testing/src/**/fixtures/**/*" + ] }, "test": { "executor": "@angular-devkit/build-angular:karma", @@ -61,6 +76,5 @@ ] } } - }, - "tags": ["component", "npm"] + } } diff --git a/libs/components/list-builder/testing/eslint.config.js b/libs/components/list-builder/testing/eslint.config.js new file mode 100644 index 0000000000..b4c331fb6a --- /dev/null +++ b/libs/components/list-builder/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/list-builder/testing/karma.conf.js b/libs/components/list-builder/testing/karma.conf.js new file mode 100644 index 0000000000..ffa1db843c --- /dev/null +++ b/libs/components/list-builder/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/list-builder/testing', + ), + }, + }); +}; diff --git a/libs/components/list-builder/testing/ng-package.json b/libs/components/list-builder/testing/ng-package.json new file mode 100644 index 0000000000..fbafcc4448 --- /dev/null +++ b/libs/components/list-builder/testing/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/libs/components/list-builder/testing/project.json b/libs/components/list-builder/testing/project.json new file mode 100644 index 0000000000..14fbb8958d --- /dev/null +++ b/libs/components/list-builder/testing/project.json @@ -0,0 +1,47 @@ +{ + "name": "list-builder-testing", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/components/list-builder/testing/src", + "prefix": "sky", + "tags": ["testing"], + "targets": { + "test": { + "executor": "@angular-devkit/build-angular:karma", + "options": { + "tsConfig": "libs/components/list-builder/testing/tsconfig.spec.json", + "karmaConfig": "libs/components/list-builder/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/list-builder/testing/src/modules/list/list-harness-filters.ts b/libs/components/list-builder/testing/src/modules/list/list-harness-filters.ts new file mode 100644 index 0000000000..33b12b57b6 --- /dev/null +++ b/libs/components/list-builder/testing/src/modules/list/list-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 `SkyListHarness` instances. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type +export interface SkyListHarnessFilters extends SkyHarnessFilters {} diff --git a/libs/components/list-builder/testing/src/modules/list/list-harness.spec.ts b/libs/components/list-builder/testing/src/modules/list/list-harness.spec.ts new file mode 100644 index 0000000000..6fdc78d6b0 --- /dev/null +++ b/libs/components/list-builder/testing/src/modules/list/list-harness.spec.ts @@ -0,0 +1,73 @@ +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 { expect } from '@skyux-sdk/testing'; +import { SkyListModule } from '@skyux/list-builder'; + +import { SkyListHarness } from './list-harness'; + +//#region Test Component +@Component({ + selector: 'sky-list-test', + template: ` + + + `, + standalone: false, +}) +class TestComponent { + public data = [ + { id: '1', name: 'Item 1' }, + { id: '2', name: 'Item 2' }, + ]; + + public otherData = [{ id: '3', name: 'Item 3' }]; +} +//#endregion Test Component + +describe('List harness', () => { + async function setupTest(options: { dataSkyId?: string } = {}): Promise<{ + listHarness: SkyListHarness; + fixture: ComponentFixture; + loader: HarnessLoader; + }> { + await TestBed.configureTestingModule({ + declarations: [TestComponent], + imports: [SkyListModule], + }).compileComponents(); + + const fixture = TestBed.createComponent(TestComponent); + const loader = TestbedHarnessEnvironment.loader(fixture); + + let listHarness: SkyListHarness; + + if (options.dataSkyId) { + listHarness = await loader.getHarness( + SkyListHarness.with({ + dataSkyId: options.dataSkyId, + }), + ); + } else { + listHarness = await loader.getHarness(SkyListHarness); + } + + fixture.detectChanges(); + + return { listHarness, fixture, loader }; + } + + it('should get the list from its data-sky-id', async () => { + const { listHarness } = await setupTest({ + dataSkyId: 'other-list', + }); + const id = await listHarness.getId(); + expect(id).toBeTruthy(); + }); + + it('should get the list id', async () => { + const { listHarness } = await setupTest(); + const id = await listHarness.getId(); + expect(id).toContain('sky-list-cmp-'); + }); +}); diff --git a/libs/components/list-builder/testing/src/modules/list/list-harness.ts b/libs/components/list-builder/testing/src/modules/list/list-harness.ts new file mode 100644 index 0000000000..713feb4e1b --- /dev/null +++ b/libs/components/list-builder/testing/src/modules/list/list-harness.ts @@ -0,0 +1,33 @@ +import { HarnessPredicate } from '@angular/cdk/testing'; +import { SkyComponentHarness } from '@skyux/core/testing'; + +import { SkyListHarnessFilters } from './list-harness-filters'; + +/** + * Harness for interacting with a list component in tests. + * @deprecated `SkyListComponent` and its features are deprecated. Use the data manager instead. + */ +export class SkyListHarness extends SkyComponentHarness { + /** + * @internal + */ + public static hostSelector = 'sky-list'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyListHarness` that meets certain criteria. + */ + public static with( + filters: SkyListHarnessFilters, + ): HarnessPredicate { + return SkyListHarness.getDataSkyIdPredicate(filters); + } + + /** + * Gets the list's unique ID. + */ + public async getId(): Promise { + const host = await this.host(); + return await host.getAttribute('id'); + } +} diff --git a/libs/components/list-builder/testing/src/public-api.ts b/libs/components/list-builder/testing/src/public-api.ts new file mode 100644 index 0000000000..ed3902d2e0 --- /dev/null +++ b/libs/components/list-builder/testing/src/public-api.ts @@ -0,0 +1,2 @@ +export { SkyListHarness } from './modules/list/list-harness'; +export { SkyListHarnessFilters } from './modules/list/list-harness-filters'; diff --git a/libs/components/list-builder/testing/tsconfig.spec.json b/libs/components/list-builder/testing/tsconfig.spec.json new file mode 100644 index 0000000000..aad2a9f430 --- /dev/null +++ b/libs/components/list-builder/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/libs/components/text-editor/package.json b/libs/components/text-editor/package.json index fb5b65b463..93ebd658bc 100644 --- a/libs/components/text-editor/package.json +++ b/libs/components/text-editor/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/text-editor/project.json b/libs/components/text-editor/project.json index 41a3b38ab4..a2b7d95488 100644 --- a/libs/components/text-editor/project.json +++ b/libs/components/text-editor/project.json @@ -4,6 +4,7 @@ "projectType": "library", "sourceRoot": "libs/components/text-editor/src", "prefix": "sky", + "tags": ["component", "npm"], "targets": { "build": { "executor": "@nx/angular:package", @@ -19,7 +20,21 @@ "tsConfig": "libs/components/text-editor/tsconfig.lib.json" } }, - "defaultConfiguration": "production" + "defaultConfiguration": "production", + "dependsOn": [ + "^build", + { + "projects": ["core"], + "target": "build" + } + ], + "inputs": [ + "buildInputs", + "^buildInputs", + "{workspaceRoot}/libs/components/text-editor/testing/src/**/*", + "!{workspaceRoot}/libs/components/text-editor/testing/src/**/*.spec.ts", + "!{workspaceRoot}/libs/components/text-editor/testing/src/**/fixtures/**/*" + ] }, "test": { "executor": "@angular-devkit/build-angular:karma", @@ -61,6 +76,5 @@ ] } } - }, - "tags": ["component", "npm"] + } } diff --git a/libs/components/text-editor/testing/eslint.config.js b/libs/components/text-editor/testing/eslint.config.js new file mode 100644 index 0000000000..b4c331fb6a --- /dev/null +++ b/libs/components/text-editor/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/text-editor/testing/karma.conf.js b/libs/components/text-editor/testing/karma.conf.js new file mode 100644 index 0000000000..3138520932 --- /dev/null +++ b/libs/components/text-editor/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/text-editor/testing', + ), + }, + }); +}; diff --git a/libs/components/text-editor/testing/ng-package.json b/libs/components/text-editor/testing/ng-package.json new file mode 100644 index 0000000000..fbafcc4448 --- /dev/null +++ b/libs/components/text-editor/testing/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/libs/components/text-editor/testing/project.json b/libs/components/text-editor/testing/project.json new file mode 100644 index 0000000000..c58aefb7a7 --- /dev/null +++ b/libs/components/text-editor/testing/project.json @@ -0,0 +1,47 @@ +{ + "name": "text-editor-testing", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/components/text-editor/testing/src", + "prefix": "sky", + "tags": ["testing"], + "targets": { + "test": { + "executor": "@angular-devkit/build-angular:karma", + "options": { + "tsConfig": "libs/components/text-editor/testing/tsconfig.spec.json", + "karmaConfig": "libs/components/text-editor/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/text-editor/testing/src/modules/text-editor/text-editor-harness-filters.ts b/libs/components/text-editor/testing/src/modules/text-editor/text-editor-harness-filters.ts new file mode 100644 index 0000000000..ab6684f400 --- /dev/null +++ b/libs/components/text-editor/testing/src/modules/text-editor/text-editor-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 `SkyTextEditorHarness` instances. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type +export interface SkyTextEditorHarnessFilters extends SkyHarnessFilters {} diff --git a/libs/components/text-editor/testing/src/modules/text-editor/text-editor-harness.spec.ts b/libs/components/text-editor/testing/src/modules/text-editor/text-editor-harness.spec.ts new file mode 100644 index 0000000000..37541c63ef --- /dev/null +++ b/libs/components/text-editor/testing/src/modules/text-editor/text-editor-harness.spec.ts @@ -0,0 +1,156 @@ +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 { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { expect } from '@skyux-sdk/testing'; +import { SkyTextEditorModule } from '@skyux/text-editor'; + +import { SkyTextEditorHarness } from './text-editor-harness'; + +//#region Test Component +@Component({ + selector: 'sky-text-editor-test', + template: ` +
+ + + + +
+ `, + standalone: false, +}) +class TestComponent { + public form = new FormGroup({ + content: new FormControl(''), + otherContent: new FormControl(''), + noLabelContent: new FormControl(''), + requiredContent: new FormControl('', Validators.required), + }); +} +//#endregion Test Component + +describe('Text editor harness', () => { + async function setupTest(options: { dataSkyId?: string } = {}): Promise<{ + textEditorHarness: SkyTextEditorHarness; + fixture: ComponentFixture; + loader: HarnessLoader; + }> { + await TestBed.configureTestingModule({ + declarations: [TestComponent], + imports: [SkyTextEditorModule, ReactiveFormsModule], + }).compileComponents(); + + const fixture = TestBed.createComponent(TestComponent); + const loader = TestbedHarnessEnvironment.loader(fixture); + + let textEditorHarness: SkyTextEditorHarness; + + if (options.dataSkyId) { + textEditorHarness = await loader.getHarness( + SkyTextEditorHarness.with({ + dataSkyId: options.dataSkyId, + }), + ); + } else { + textEditorHarness = await loader.getHarness(SkyTextEditorHarness); + } + + fixture.detectChanges(); + + return { textEditorHarness, fixture, loader }; + } + + it('should get the text editor from its data-sky-id', async () => { + const { textEditorHarness } = await setupTest({ + dataSkyId: 'other-editor', + }); + const labelText = await textEditorHarness.getLabelText(); + expect(labelText).toBe('Other'); + }); + + it('should get the label text', async () => { + const { textEditorHarness } = await setupTest(); + const labelText = await textEditorHarness.getLabelText(); + expect(labelText).toBe('Description'); + }); + + it('should return undefined when no label is present', async () => { + const { textEditorHarness } = await setupTest({ + dataSkyId: 'no-label-editor', + }); + const labelText = await textEditorHarness.getLabelText(); + expect(labelText).toBeUndefined(); + }); + + it('should get the hint text', async () => { + const { textEditorHarness } = await setupTest(); + const hintText = await textEditorHarness.getHintText(); + expect(hintText).toBe('Enter a description'); + }); + + it('should return undefined when no hint text is present', async () => { + const { textEditorHarness } = await setupTest({ + dataSkyId: 'other-editor', + }); + const hintText = await textEditorHarness.getHintText(); + expect(hintText).toBeUndefined(); + }); + + it('should check if the text editor is disabled', async () => { + const { textEditorHarness, fixture } = await setupTest(); + expect(await textEditorHarness.isDisabled()).toBe(false); + + fixture.componentInstance.form.get('content')?.disable(); + fixture.detectChanges(); + + expect(await textEditorHarness.isDisabled()).toBe(true); + }); + + it('should check if the text editor has errors', async () => { + const { textEditorHarness } = await setupTest({ + dataSkyId: 'required-editor', + }); + expect(await textEditorHarness.hasErrors()).toBe(false); + }); + + it('should check if the text editor is focused', async () => { + const { textEditorHarness } = await setupTest(); + expect(await textEditorHarness.isFocused()).toBe(false); + }); + + it('should check if the label is marked as required', async () => { + const { textEditorHarness } = await setupTest({ + dataSkyId: 'required-editor', + }); + expect(await textEditorHarness.isRequired()).toBe(true); + }); + + it('should return false for isRequired when no label is present', async () => { + const { textEditorHarness } = await setupTest({ + dataSkyId: 'no-label-editor', + }); + expect(await textEditorHarness.isRequired()).toBe(false); + }); +}); diff --git a/libs/components/text-editor/testing/src/modules/text-editor/text-editor-harness.ts b/libs/components/text-editor/testing/src/modules/text-editor/text-editor-harness.ts new file mode 100644 index 0000000000..aeda3d3068 --- /dev/null +++ b/libs/components/text-editor/testing/src/modules/text-editor/text-editor-harness.ts @@ -0,0 +1,86 @@ +import { HarnessPredicate } from '@angular/cdk/testing'; +import { SkyComponentHarness } from '@skyux/core/testing'; + +import { SkyTextEditorHarnessFilters } from './text-editor-harness-filters'; + +/** + * Harness for interacting with a text editor component in tests. + */ +export class SkyTextEditorHarness extends SkyComponentHarness { + /** + * @internal + */ + public static hostSelector = 'sky-text-editor'; + + #getWrapper = this.locatorFor('.sky-text-editor'); + #getLabelElement = this.locatorForOptional('.sky-control-label'); + #getHintText = this.locatorForOptional('.sky-text-editor-hint-text'); + + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyTextEditorHarness` that meets certain criteria. + */ + public static with( + filters: SkyTextEditorHarnessFilters, + ): HarnessPredicate { + return SkyTextEditorHarness.getDataSkyIdPredicate(filters); + } + + /** + * Gets the label text for the text editor. + */ + public async getLabelText(): Promise { + const label = await this.#getLabelElement(); + if (label) { + return (await label.text()).trim(); + } + return undefined; + } + + /** + * Gets the hint text for the text editor. + */ + public async getHintText(): Promise { + const hintText = await this.#getHintText(); + if (hintText) { + const text = (await hintText.text()).trim(); + return text || undefined; + } + return undefined; + } + + /** + * Checks if the text editor is disabled. + */ + public async isDisabled(): Promise { + const wrapper = await this.#getWrapper(); + return await wrapper.hasClass('sky-text-editor-disabled'); + } + + /** + * Checks if the text editor has validation errors. + */ + public async hasErrors(): Promise { + const wrapper = await this.#getWrapper(); + return await wrapper.hasClass('sky-text-editor-invalid'); + } + + /** + * Checks if the text editor is focused. + */ + public async isFocused(): Promise { + const wrapper = await this.#getWrapper(); + return await wrapper.hasClass('sky-text-editor-wrapper-focused'); + } + + /** + * Checks if the label is marked as required. + */ + public async isRequired(): Promise { + const label = await this.#getLabelElement(); + if (label) { + return await label.hasClass('sky-control-label-required'); + } + return false; + } +} diff --git a/libs/components/text-editor/testing/src/public-api.ts b/libs/components/text-editor/testing/src/public-api.ts new file mode 100644 index 0000000000..5bef80f2db --- /dev/null +++ b/libs/components/text-editor/testing/src/public-api.ts @@ -0,0 +1,2 @@ +export { SkyTextEditorHarness } from './modules/text-editor/text-editor-harness'; +export { SkyTextEditorHarnessFilters } from './modules/text-editor/text-editor-harness-filters'; diff --git a/libs/components/text-editor/testing/tsconfig.spec.json b/libs/components/text-editor/testing/tsconfig.spec.json new file mode 100644 index 0000000000..aad2a9f430 --- /dev/null +++ b/libs/components/text-editor/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..4ea0e32f97 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" @@ -124,6 +127,9 @@ "@skyux/list-builder-view-grids/testing": [ "libs/components/list-builder-view-grids/testing/src/public-api.ts" ], + "@skyux/list-builder/testing": [ + "libs/components/list-builder/testing/src/public-api.ts" + ], "@skyux/lists": ["libs/components/lists/src/index.ts"], "@skyux/lists/testing": [ "libs/components/lists/testing/src/public-api.ts" @@ -183,6 +189,9 @@ "@skyux/tabs": ["libs/components/tabs/src/index.ts"], "@skyux/tabs/testing": ["libs/components/tabs/testing/src/public-api.ts"], "@skyux/text-editor": ["libs/components/text-editor/src/index.ts"], + "@skyux/text-editor/testing": [ + "libs/components/text-editor/testing/src/public-api.ts" + ], "@skyux/theme": ["libs/components/theme/src/index.ts"], "@skyux/tiles": ["libs/components/tiles/src/index.ts"], "@skyux/tiles/testing": [