From d38fed297d5e2bc15eb972b9c7c1c60fb18dfc46 Mon Sep 17 00:00:00 2001 From: Lorenz Hilpert Date: Mon, 31 Mar 2025 12:29:22 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20input=20controls=20and=20chec?= =?UTF-8?q?kbox=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduced a new input controls library with a checkbox component. - โœจ **Feature**: Added input controls library with checkbox component - ๐ŸŽจ **Style**: Updated checkbox component styles and structure - ๐Ÿงช **Test**: Added unit tests for checkbox and empty state components - ๐Ÿ› ๏ธ **Refactor**: Improved checkbox component code and removed unused styles - ๐Ÿ“š **Docs**: Updated commit message guidelines in VSCode settings --- .vscode/settings.json | 2 +- apps/isa-app/src/ui.scss | 1 + docs/guidelines/testing.md | 172 +++++++++++++++++- .../src/lib/empty-state.component.spec.ts | 29 +++ .../ui/input-controls/src/input-controls.scss | 1 + ...checkbox.component.scss => _checkbox.scss} | 28 +-- .../src/lib/checkbox/checkbox.component.html | 3 +- .../lib/checkbox/checkbox.component.spec.ts | 49 +++++ .../src/lib/checkbox/checkbox.component.ts | 4 +- package-lock.json | 17 ++ package.json | 1 + 11 files changed, 282 insertions(+), 25 deletions(-) create mode 100644 libs/ui/empty-state/src/lib/empty-state.component.spec.ts create mode 100644 libs/ui/input-controls/src/input-controls.scss rename libs/ui/input-controls/src/lib/checkbox/{checkbox.component.scss => _checkbox.scss} (74%) create mode 100644 libs/ui/input-controls/src/lib/checkbox/checkbox.component.spec.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 7959937d2..db08bce69 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,7 +23,7 @@ ], "github.copilot.chat.commitMessageGeneration.instructions": [ { - "file": "docs/guidelines/commit-message.md" + "file": ".github/commit-instructions.md" } ], "github.copilot.chat.codeGeneration.instructions": [ diff --git a/apps/isa-app/src/ui.scss b/apps/isa-app/src/ui.scss index 3f0a40112..4d17d9864 100644 --- a/apps/isa-app/src/ui.scss +++ b/apps/isa-app/src/ui.scss @@ -1,2 +1,3 @@ @use "../../../libs/ui/buttons/src/buttons.scss"; +@use "../../../libs/ui/input-controls/src/input-controls.scss"; @use "../../../libs/ui/progress-bar/src/lib/progress-bar.scss"; diff --git a/docs/guidelines/testing.md b/docs/guidelines/testing.md index e63fe4835..d31e97eb8 100644 --- a/docs/guidelines/testing.md +++ b/docs/guidelines/testing.md @@ -2,13 +2,47 @@ ## Unit Testing Requirements -- Test files should end with `.spec.ts`. -- Use Spectator for Component, Directive and Service tests. -- Use Jest as the test runner. -- Follow the Arrange-Act-Assert (AAA) pattern in tests. -- Mock external dependencies to isolate the unit under test. +- Test files should end with `.spec.ts` +- Use Spectator for Component, Directive and Service tests +- Use Jest as the test runner +- Follow the Arrange-Act-Assert (AAA) pattern in tests +- Mock external dependencies to isolate the unit under test +- Mock child components to ensure true unit testing isolation -## Example Test Structure +## Best Practices + +### Component Testing + +- Use `createComponentFactory` for standalone components +- Use `createHostFactory` when testing components with templates +- Mock child components using `ng-mocks` +- Test component inputs, outputs, and lifecycle hooks +- Verify DOM rendering and component behavior separately + +### Mocking Child Components + +Always mock child components to: + +- Isolate the component under test +- Prevent unintended side effects +- Reduce test complexity +- Improve test performance + +```typescript +import { MockComponent } from 'ng-mocks'; +import { ChildComponent } from './child.component'; + +describe('ParentComponent', () => { + const createComponent = createComponentFactory({ + component: ParentComponent, + declarations: [MockComponent(ChildComponent)], + }); +}); +``` + +## Example Test Structures + +### Basic Component Test ```typescript import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; @@ -27,9 +61,135 @@ describe('MyComponent', () => { }); it('should handle action correctly', () => { + // Arrange spectator.setInput('inputProp', 'testValue'); + + // Act spectator.click('button'); + + // Assert expect(spectator.component.outputProp).toBe('expectedValue'); }); }); ``` + +### Host Component Test with Child Components + +```typescript +import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; +import { ParentComponent } from './parent.component'; +import { ChildComponent } from './child.component'; +import { MockComponent } from 'ng-mocks'; + +describe('ParentComponent', () => { + let spectator: SpectatorHost; + + const createHost = createHostFactory({ + component: ParentComponent, + declarations: [MockComponent(ChildComponent)], + template: ` + + `, + }); + + beforeEach(() => { + spectator = createHost(undefined, { + hostProps: { + inputValue: 'test', + handleOutput: jest.fn(), + }, + }); + }); + + it('should pass input to child component', () => { + // Arrange + const childComponent = spectator.query(ChildComponent); + + // Assert + expect(childComponent.input).toBe('test'); + }); +}); +``` + +### Testing Events and Outputs + +```typescript +it('should emit when button is clicked', () => { + // Arrange + const outputSpy = jest.fn(); + spectator.component.outputEvent.subscribe(outputSpy); + + // Act + spectator.click('button'); + + // Assert + expect(outputSpy).toHaveBeenCalledWith(expectedValue); +}); +``` + +## Common Patterns + +### Query Elements + +```typescript +// By CSS selector +const element = spectator.query('.class-name'); + +// By directive/component +const child = spectator.query(ChildComponent); + +// Multiple elements +const elements = spectator.queryAll('.item'); +``` + +### Trigger Events + +```typescript +// Click events +spectator.click('.button'); +spectator.click(buttonElement); + +// Input events +spectator.typeInElement('value', 'input'); + +// Custom events +spectator.triggerEventHandler(MyComponent, 'eventName', eventValue); +``` + +### Test Async Operations + +```typescript +it('should handle async operations', async () => { + // Arrange + const response = { data: 'test' }; + service.getData.mockResolvedValue(response); + + // Act + await spectator.component.loadData(); + + // Assert + expect(spectator.component.data).toEqual(response); +}); +``` + +## Tips and Tricks + +1. **Debugging Tests** + + - Use `spectator.debug()` to log the current DOM state + - Use `console.log` sparingly and remove before committing + - Set breakpoints in your IDE for step-by-step debugging + +2. **Common Pitfalls** + + - Don't test implementation details + - Avoid testing third-party libraries + - Don't test multiple concerns in a single test + - Remember to clean up subscriptions + +3. **Performance** + - Mock heavy dependencies + - Keep test setup minimal + - Use `beforeAll` for expensive operations shared across tests diff --git a/libs/ui/empty-state/src/lib/empty-state.component.spec.ts b/libs/ui/empty-state/src/lib/empty-state.component.spec.ts new file mode 100644 index 000000000..fd207471e --- /dev/null +++ b/libs/ui/empty-state/src/lib/empty-state.component.spec.ts @@ -0,0 +1,29 @@ +import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { EmptyStateComponent } from './empty-state.component'; + +describe('EmptyStateComponent', () => { + let spectator: Spectator; + const createComponent = createComponentFactory(EmptyStateComponent); + + beforeEach(() => { + spectator = createComponent({ + props: { + title: 'Test Title', + description: 'Test Description', + }, + }); + }); + + it('should create the component', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should set title and description inputs correctly', () => { + expect(spectator.component.title()).toEqual('Test Title'); + expect(spectator.component.description()).toEqual('Test Description'); + }); + + it('should apply the host class "ui-empty-state"', () => { + expect(spectator.element.classList.contains('ui-empty-state')).toBe(true); + }); +}); diff --git a/libs/ui/input-controls/src/input-controls.scss b/libs/ui/input-controls/src/input-controls.scss new file mode 100644 index 000000000..7e1da33b3 --- /dev/null +++ b/libs/ui/input-controls/src/input-controls.scss @@ -0,0 +1 @@ +@use "./lib/checkbox/checkbox"; diff --git a/libs/ui/input-controls/src/lib/checkbox/checkbox.component.scss b/libs/ui/input-controls/src/lib/checkbox/_checkbox.scss similarity index 74% rename from libs/ui/input-controls/src/lib/checkbox/checkbox.component.scss rename to libs/ui/input-controls/src/lib/checkbox/_checkbox.scss index e4c14afb4..ed78d464d 100644 --- a/libs/ui/input-controls/src/lib/checkbox/checkbox.component.scss +++ b/libs/ui/input-controls/src/lib/checkbox/_checkbox.scss @@ -2,11 +2,11 @@ @apply relative inline-flex p-3 items-center justify-center rounded-lg bg-isa-white size-6 border border-solid border-isa-neutral-900; font-size: 1.5rem; - ng-icon { + .ui-checkbox__icon { @apply invisible min-w-6 size-6 text-isa-white; } - input[type='checkbox'] { + input[type="checkbox"] { position: absolute; top: 0; left: 0; @@ -16,22 +16,22 @@ @apply cursor-pointer; } - &:has(input[type='checkbox']:checked) { + &:has(input[type="checkbox"]:checked) { @apply bg-isa-neutral-900 text-isa-white; - ng-icon { + .ui-checkbox__icon { @apply visible; } } - &:has(input[type='checkbox']:disabled) { + &:has(input[type="checkbox"]:disabled) { @apply bg-isa-neutral-400 border-isa-neutral-400 cursor-default; - &:has(input[type='checkbox']:checked) { + &:has(input[type="checkbox"]:checked) { @apply bg-isa-neutral-400 border-isa-neutral-400; } - input[type='checkbox'] { + input[type="checkbox"] { @apply cursor-default; } } @@ -47,11 +47,11 @@ @apply rounded-full bg-isa-neutral-300 size-12; - ng-icon { + .ui-checkbox__icon { @apply invisible size-6 text-isa-neutral-100; } - input[type='checkbox'] { + input[type="checkbox"] { position: absolute; top: 0; left: 0; @@ -65,10 +65,10 @@ @apply bg-isa-neutral-400; } - &:has(input[type='checkbox']:checked) { + &:has(input[type="checkbox"]:checked) { @apply bg-isa-neutral-700; - ng-icon { + .ui-checkbox__icon { @apply visible; } @@ -77,14 +77,14 @@ } } - &:has(input[type='checkbox']:disabled) { + &:has(input[type="checkbox"]:disabled) { @apply bg-isa-neutral-400 cursor-default; - &:has(input[type='checkbox']:checked) { + &:has(input[type="checkbox"]:checked) { @apply bg-isa-neutral-700; } - input[type='checkbox'] { + input[type="checkbox"] { @apply cursor-default; } } diff --git a/libs/ui/input-controls/src/lib/checkbox/checkbox.component.html b/libs/ui/input-controls/src/lib/checkbox/checkbox.component.html index a3b2a4f21..5ecc91872 100644 --- a/libs/ui/input-controls/src/lib/checkbox/checkbox.component.html +++ b/libs/ui/input-controls/src/lib/checkbox/checkbox.component.html @@ -1 +1,2 @@ - + + diff --git a/libs/ui/input-controls/src/lib/checkbox/checkbox.component.spec.ts b/libs/ui/input-controls/src/lib/checkbox/checkbox.component.spec.ts new file mode 100644 index 000000000..68eb1fd4c --- /dev/null +++ b/libs/ui/input-controls/src/lib/checkbox/checkbox.component.spec.ts @@ -0,0 +1,49 @@ +import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; +import { CheckboxComponent, CheckboxAppearance } from './checkbox.component'; +import { MockComponent } from 'ng-mocks'; +import { NgIconComponent } from '@ng-icons/core'; + +describe('CheckboxComponent', () => { + let spectator: SpectatorHost; + const createHost = createHostFactory({ + component: CheckboxComponent, + declarations: [MockComponent(NgIconComponent)], + template: `>`, + }); + + beforeEach(() => { + spectator = createHost(undefined, { + hostProps: { + appearance: CheckboxAppearance.Checkbox, + }, + }); + }); + + it('should create', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should use default appearance as Checkbox', () => { + // Assert default appearance value and computed class + expect(spectator.component.appearance()).toEqual(CheckboxAppearance.Checkbox); + expect(spectator.component.appearanceClass()).toEqual('ui-checkbox__checkbox'); + }); + + it('should compute bullet class when appearance is set to Bullet', () => { + // Act: update appearance signal to Bullet + spectator.setHostInput('appearance', CheckboxAppearance.Bullet); + + // Assert updated appearance & computed class + expect(spectator.component.appearance()).toEqual(CheckboxAppearance.Bullet); + expect(spectator.component.appearanceClass()).toEqual('ui-checkbox__bullet'); + }); + + it('should render checkbox input element', () => { + // Arrange & Act + const checkbox = spectator.query('input[type="checkbox"]'); + + // Assert + expect(checkbox).toBeTruthy(); + expect(checkbox).toHaveAttribute('type', 'checkbox'); + }); +}); diff --git a/libs/ui/input-controls/src/lib/checkbox/checkbox.component.ts b/libs/ui/input-controls/src/lib/checkbox/checkbox.component.ts index 8056efe23..e8c03ee79 100644 --- a/libs/ui/input-controls/src/lib/checkbox/checkbox.component.ts +++ b/libs/ui/input-controls/src/lib/checkbox/checkbox.component.ts @@ -13,13 +13,11 @@ export const CheckboxAppearance = { Checkbox: 'checkbox', } as const; -export type CheckboxAppearance = - (typeof CheckboxAppearance)[keyof typeof CheckboxAppearance]; +export type CheckboxAppearance = (typeof CheckboxAppearance)[keyof typeof CheckboxAppearance]; @Component({ selector: 'ui-checkbox', templateUrl: './checkbox.component.html', - styleUrls: ['./checkbox.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, imports: [NgIconComponent], diff --git a/package-lock.json b/package-lock.json index ff756694d..3f94e5e39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,6 +101,7 @@ "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", "karma-junit-reporter": "~2.0.1", + "ng-mocks": "^14.13.4", "ng-swagger-gen": "^2.3.1", "nx": "20.4.6", "postcss": "^8.5.3", @@ -23807,6 +23808,22 @@ "dev": true, "license": "MIT" }, + "node_modules/ng-mocks": { + "version": "14.13.4", + "resolved": "https://registry.npmjs.org/ng-mocks/-/ng-mocks-14.13.4.tgz", + "integrity": "sha512-OFpzcx9vzeMqpVaBaukH8gvHRhor8iAkc8pOWakSPwxD3DuoHyDrjb/odgjI3Jq+Iaerqb3js1I4Sluu+0rLSQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/help-me-mom" + }, + "peerDependencies": { + "@angular/common": "5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19", + "@angular/core": "5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19", + "@angular/forms": "5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19", + "@angular/platform-browser": "5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18 || 19.0.0-alpha - 19" + } + }, "node_modules/ng-swagger-gen": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/ng-swagger-gen/-/ng-swagger-gen-2.3.1.tgz", diff --git a/package.json b/package.json index 7785eb31c..93b3b0f89 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", "karma-junit-reporter": "~2.0.1", + "ng-mocks": "^14.13.4", "ng-swagger-gen": "^2.3.1", "nx": "20.4.6", "postcss": "^8.5.3",