diff --git a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts index 25d45dec7..4c6e506e0 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts @@ -10,8 +10,8 @@ import { import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected.pipe'; -import { LabelByValuePipe } from '../../pipes/internal/label-by-value.pipe'; +import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected/check-option-selected.pipe'; +import { LabelByValuePipe } from '../../pipes/internal/label-by-value/label-by-value.pipe'; import { CpsMenuHideReason } from '../cps-menu/cps-menu.component'; import { CpsAutocompleteComponent } from './cps-autocomplete.component'; import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../services/cps-root-font-size/cps-root-font-size.service'; diff --git a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.ts b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.ts index 1b212dd3d..5e02e0c2c 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.ts @@ -24,12 +24,12 @@ import { Validators } from '@angular/forms'; import { Subject, debounceTime, distinctUntilChanged, takeUntil } from 'rxjs'; -import { convertSize } from '../../utils/internal/size-utils'; +import { convertSize } from '../../utils/internal/size-utils/size-utils'; import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../services/cps-root-font-size/cps-root-font-size.service'; import { generateUniqueId, logMissingAriaLabelError -} from '../../utils/internal/accessibility-utils'; +} from '../../utils/internal/accessibility-utils/accessibility-utils'; import { CpsIconComponent, IconType, @@ -38,8 +38,8 @@ import { import { CpsChipComponent } from '../cps-chip/cps-chip.component'; import { CpsProgressLinearComponent } from '../cps-progress-linear/cps-progress-linear.component'; import { CpsInfoCircleComponent } from '../cps-info-circle/cps-info-circle.component'; -import { LabelByValuePipe } from '../../pipes/internal/label-by-value.pipe'; -import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected.pipe'; +import { LabelByValuePipe } from '../../pipes/internal/label-by-value/label-by-value.pipe'; +import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected/check-option-selected.pipe'; import { isEqual } from 'lodash-es'; import { CpsTooltipPosition } from '../../directives/cps-tooltip/cps-tooltip.directive'; import { diff --git a/projects/cps-ui-kit/src/lib/components/cps-button-toggle/cps-button-toggle.component.ts b/projects/cps-ui-kit/src/lib/components/cps-button-toggle/cps-button-toggle.component.ts index a110dfa8b..85826de17 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-button-toggle/cps-button-toggle.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-button-toggle/cps-button-toggle.component.ts @@ -18,7 +18,7 @@ import { import { ControlValueAccessor, NgControl } from '@angular/forms'; import { isEqual } from 'lodash-es'; import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../services/cps-root-font-size/cps-root-font-size.service'; -import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected.pipe'; +import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected/check-option-selected.pipe'; import { CpsInfoCircleComponent } from '../cps-info-circle/cps-info-circle.component'; import { CpsIconComponent } from '../cps-icon/cps-icon.component'; import { @@ -28,7 +28,7 @@ import { import { generateUniqueId, logMissingAriaLabelError -} from '../../utils/internal/accessibility-utils'; +} from '../../utils/internal/accessibility-utils/accessibility-utils'; /** * CpsButtonToggleOption is used to define the options of the CpsButtonToggleComponent. diff --git a/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.ts b/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.ts index f4a78e824..0cf951139 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-button/cps-button.component.ts @@ -8,13 +8,16 @@ import { OnChanges, OnInit, Output, - SimpleChanges + type SimpleChanges } from '@angular/core'; -import { getCSSColor } from '../../utils/colors-utils'; +import { getCSSColor } from '../../utils/colors-utils/colors-utils'; import { CpsIconComponent, IconType } from '../cps-icon/cps-icon.component'; import { CpsProgressCircularComponent } from '../cps-progress-circular/cps-progress-circular.component'; -import { convertSize, parseSize } from '../../utils/internal/size-utils'; -import { logMissingAriaLabelError } from '../../utils/internal/accessibility-utils'; +import { + convertSize, + parseSize +} from '../../utils/internal/size-utils/size-utils'; +import { logMissingAriaLabelError } from '../../utils/internal/accessibility-utils/accessibility-utils'; /** * CpsButtonComponent is a button element. diff --git a/projects/cps-ui-kit/src/lib/components/cps-checkbox/cps-checkbox.component.ts b/projects/cps-ui-kit/src/lib/components/cps-checkbox/cps-checkbox.component.ts index 9995e362a..64121e3b6 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-checkbox/cps-checkbox.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-checkbox/cps-checkbox.component.ts @@ -16,8 +16,8 @@ import { ControlValueAccessor, NgControl } from '@angular/forms'; import { CpsInfoCircleComponent } from '../cps-info-circle/cps-info-circle.component'; import { CpsTooltipPosition } from '../../directives/cps-tooltip/cps-tooltip.directive'; import { CpsIconComponent, IconType } from '../cps-icon/cps-icon.component'; -import { getCSSColor } from '../../utils/colors-utils'; -import { logMissingAriaLabelError } from '../../utils/internal/accessibility-utils'; +import { getCSSColor } from '../../utils/colors-utils/colors-utils'; +import { logMissingAriaLabelError } from '../../utils/internal/accessibility-utils/accessibility-utils'; /** * CpsCheckboxComponent is a checkbox element. diff --git a/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.ts b/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.ts index 4b787359f..70a69d690 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.ts @@ -15,7 +15,7 @@ import { import { ControlValueAccessor, FormsModule, NgControl } from '@angular/forms'; import { CpsInputComponent } from '../cps-input/cps-input.component'; import { Subscription } from 'rxjs'; -import { convertSize } from '../../utils/internal/size-utils'; +import { convertSize } from '../../utils/internal/size-utils/size-utils'; import { CpsTooltipPosition } from '../../directives/cps-tooltip/cps-tooltip.directive'; import { CpsMenuComponent, @@ -25,7 +25,7 @@ import { DatePicker, DatePickerModule } from 'primeng/datepicker'; import { logMissingAriaLabelError, generateUniqueId -} from '../../utils/internal/accessibility-utils'; +} from '../../utils/internal/accessibility-utils/accessibility-utils'; /** * CpsDatepickerAppearanceType is used to define the border of the datepicker input. diff --git a/projects/cps-ui-kit/src/lib/components/cps-divider/cps-divider.component.ts b/projects/cps-ui-kit/src/lib/components/cps-divider/cps-divider.component.ts index 00954b1bb..f8ff335a7 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-divider/cps-divider.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-divider/cps-divider.component.ts @@ -5,8 +5,8 @@ import { inject, input } from '@angular/core'; -import { getCSSColor } from '../../utils/colors-utils'; -import { convertSize } from '../../utils/internal/size-utils'; +import { getCSSColor } from '../../utils/colors-utils/colors-utils'; +import { convertSize } from '../../utils/internal/size-utils/size-utils'; import { DOCUMENT } from '@angular/common'; /** diff --git a/projects/cps-ui-kit/src/lib/components/cps-expansion-panel/cps-expansion-panel.component.ts b/projects/cps-ui-kit/src/lib/components/cps-expansion-panel/cps-expansion-panel.component.ts index 3e3177706..9fbb78b70 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-expansion-panel/cps-expansion-panel.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-expansion-panel/cps-expansion-panel.component.ts @@ -14,9 +14,9 @@ import { type SimpleChanges } from '@angular/core'; import { CpsIconComponent, IconType } from '../cps-icon/cps-icon.component'; -import { getCSSColor } from '../../utils/colors-utils'; -import { convertSize } from '../../utils/internal/size-utils'; -import { generateUniqueId } from '../../utils/internal/accessibility-utils'; +import { getCSSColor } from '../../utils/colors-utils/colors-utils'; +import { convertSize } from '../../utils/internal/size-utils/size-utils'; +import { generateUniqueId } from '../../utils/internal/accessibility-utils/accessibility-utils'; import { AnimationBuilder, AnimationFactory, @@ -30,7 +30,7 @@ import { import { prefersReducedMotion, REDUCED_MOTION_DURATION -} from '../../utils/internal/motion-utils'; +} from '../../utils/internal/motion-utils/motion-utils'; const transitionType = '0.2s cubic-bezier(0.4, 0, 0.2, 1)'; diff --git a/projects/cps-ui-kit/src/lib/components/cps-file-upload/cps-file-upload.component.ts b/projects/cps-ui-kit/src/lib/components/cps-file-upload/cps-file-upload.component.ts index 4090a550c..2e5e33dc5 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-file-upload/cps-file-upload.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-file-upload/cps-file-upload.component.ts @@ -11,12 +11,12 @@ import { OnDestroy, OnInit, Output, - SimpleChanges, + type SimpleChanges, ViewChild } from '@angular/core'; import { catchError, Observable, of, Subject, take, takeUntil } from 'rxjs'; -import { convertSize } from '../../utils/internal/size-utils'; -import { focusElement } from '../../utils/internal/accessibility-utils'; +import { convertSize } from '../../utils/internal/size-utils/size-utils'; +import { focusElement } from '../../utils/internal/accessibility-utils/accessibility-utils'; import { CpsIconComponent } from '../cps-icon/cps-icon.component'; import { CpsTooltipDirective, diff --git a/projects/cps-ui-kit/src/lib/components/cps-icon/cps-icon.component.ts b/projects/cps-ui-kit/src/lib/components/cps-icon/cps-icon.component.ts index e5911db85..888f4b254 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-icon/cps-icon.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-icon/cps-icon.component.ts @@ -11,8 +11,8 @@ import { Renderer2, type SimpleChanges } from '@angular/core'; -import { getCSSColor } from '../../utils/colors-utils'; -import { convertSize } from '../../utils/internal/size-utils'; +import { getCSSColor } from '../../utils/colors-utils/colors-utils'; +import { convertSize } from '../../utils/internal/size-utils/size-utils'; /** * Injection token that is used to provide the path to the icons. diff --git a/projects/cps-ui-kit/src/lib/components/cps-info-circle/cps-info-circle.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-info-circle/cps-info-circle.component.spec.ts new file mode 100644 index 000000000..734e0b6e3 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-info-circle/cps-info-circle.component.spec.ts @@ -0,0 +1,69 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { CpsInfoCircleComponent } from './cps-info-circle.component'; +import { CpsIconComponent } from '../cps-icon/cps-icon.component'; +import { CpsTooltipDirective } from '../../directives/cps-tooltip/cps-tooltip.directive'; + +describe('CpsInfoCircleComponent', () => { + let component: CpsInfoCircleComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CpsInfoCircleComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(CpsInfoCircleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should apply default input values', () => { + expect(component.size).toBe('small'); + expect(component.tooltipText).toBe(''); + expect(component.tooltipPosition).toBe('top'); + expect(component.tooltipContentClass).toBe('cps-tooltip-content'); + expect(component.tooltipMaxWidth).toBe('100%'); + expect(component.tooltipPersistent).toBe(false); + }); + + it('should render a cps-icon element and pass size through to it', () => { + const icon = fixture.debugElement.query(By.directive(CpsIconComponent)); + expect(icon).toBeTruthy(); + expect(icon.componentInstance.size).toBe('small'); + }); + + it('should update the icon size when the size input changes', () => { + fixture.componentRef.setInput('size', 'large'); + fixture.detectChanges(); + + const icon = fixture.debugElement.query(By.directive(CpsIconComponent)); + expect(icon.componentInstance.size).toBe('large'); + }); + + it('should wire the CpsTooltipDirective with the configured tooltip inputs', () => { + fixture.componentRef.setInput('tooltipText', 'Some helpful text'); + fixture.componentRef.setInput('tooltipPosition', 'bottom'); + fixture.componentRef.setInput('tooltipContentClass', 'my-tooltip-class'); + fixture.componentRef.setInput('tooltipMaxWidth', 200); + fixture.componentRef.setInput('tooltipPersistent', true); + fixture.detectChanges(); + + const tooltip = fixture.debugElement.query( + By.directive(CpsTooltipDirective) + ); + expect(tooltip).toBeTruthy(); + + const directive: CpsTooltipDirective = + tooltip.injector.get(CpsTooltipDirective); + expect(directive.tooltip()).toBe('Some helpful text'); + expect(directive.tooltipPosition()).toBe('bottom'); + expect(directive.tooltipContentClass()).toBe('my-tooltip-class'); + expect(directive.tooltipMaxWidth()).toBe(200); + expect(directive.tooltipPersistent()).toBe(true); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.ts b/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.ts index 393e296f7..ecd401c4e 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.ts @@ -10,12 +10,12 @@ import { Optional, Output, Self, - SimpleChanges + type SimpleChanges } from '@angular/core'; import { ControlValueAccessor, NgControl, Validators } from '@angular/forms'; import { Subscription } from 'rxjs'; import { CpsTooltipPosition } from '../../directives/cps-tooltip/cps-tooltip.directive'; -import { convertSize } from '../../utils/internal/size-utils'; +import { convertSize } from '../../utils/internal/size-utils/size-utils'; import { CpsIconComponent, IconType, @@ -26,7 +26,7 @@ import { CpsProgressLinearComponent } from '../cps-progress-linear/cps-progress- import { generateUniqueId, logMissingAriaLabelError -} from '../../utils/internal/accessibility-utils'; +} from '../../utils/internal/accessibility-utils/accessibility-utils'; /** * CpsInputAppearanceType is used to define the border of the input field. diff --git a/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.ts b/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.ts index b0ac70864..d2bd7a157 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.ts @@ -9,8 +9,8 @@ import { OnInit, type SimpleChanges } from '@angular/core'; -import { getCSSColor } from '../../utils/colors-utils'; -import { logMissingAriaLabelError } from '../../utils/internal/accessibility-utils'; +import { getCSSColor } from '../../utils/colors-utils/colors-utils'; +import { logMissingAriaLabelError } from '../../utils/internal/accessibility-utils/accessibility-utils'; import { CPS_LIVE_ANNOUNCER_SERVICE } from '../../services/cps-live-announcer/cps-live-announcer.service'; /** diff --git a/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.ts b/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.ts index 704302078..59387f02f 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.ts @@ -23,7 +23,7 @@ import { Output, PLATFORM_ID, Renderer2, - SimpleChanges, + type SimpleChanges, ViewChild, ViewEncapsulation, ViewRef, @@ -40,7 +40,7 @@ import { CPS_FOCUS_SERVICE } from '../../services/cps-focus/cps-focus.service'; import { prefersReducedMotion, REDUCED_MOTION_DURATION -} from '../../utils/internal/motion-utils'; +} from '../../utils/internal/motion-utils/motion-utils'; type Nullable = T | null | undefined; type VoidListener = () => void | null | undefined; diff --git a/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.ts b/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.ts index cdb75a149..ab36ad5fc 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.ts @@ -14,7 +14,7 @@ import { import { DOCUMENT } from '@angular/common'; import { Paginator, PaginatorModule } from 'primeng/paginator'; import { CpsSelectComponent } from '../cps-select/cps-select.component'; -import { getCSSColor } from '../../utils/colors-utils'; +import { getCSSColor } from '../../utils/colors-utils/colors-utils'; import { FormsModule } from '@angular/forms'; import { isEqual } from 'lodash-es'; diff --git a/projects/cps-ui-kit/src/lib/components/cps-progress-circular/cps-progress-circular.component.ts b/projects/cps-ui-kit/src/lib/components/cps-progress-circular/cps-progress-circular.component.ts index 86bf4c246..a95681bb3 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-progress-circular/cps-progress-circular.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-progress-circular/cps-progress-circular.component.ts @@ -8,8 +8,8 @@ import { inject, type SimpleChanges } from '@angular/core'; -import { convertSize } from '../../utils/internal/size-utils'; -import { getCSSColor } from '../../utils/colors-utils'; +import { convertSize } from '../../utils/internal/size-utils/size-utils'; +import { getCSSColor } from '../../utils/colors-utils/colors-utils'; /** * CpsProgressCircularComponent is a process status indicator in a form of a spinner. diff --git a/projects/cps-ui-kit/src/lib/components/cps-progress-linear/cps-progress-linear.component.ts b/projects/cps-ui-kit/src/lib/components/cps-progress-linear/cps-progress-linear.component.ts index 13743ab92..d665c5bd2 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-progress-linear/cps-progress-linear.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-progress-linear/cps-progress-linear.component.ts @@ -6,8 +6,8 @@ import { inject, input } from '@angular/core'; -import { getCSSColor } from '../../utils/colors-utils'; -import { convertSize } from '../../utils/internal/size-utils'; +import { getCSSColor } from '../../utils/colors-utils/colors-utils'; +import { convertSize } from '../../utils/internal/size-utils/size-utils'; /** * CpsProgressLinearComponent is a process status indicator of a rectangular form. diff --git a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.spec.ts new file mode 100644 index 000000000..db5f6a3f5 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.spec.ts @@ -0,0 +1,152 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CpsRadioButtonComponent } from './cps-radio-button.component'; + +describe('CpsRadioButtonComponent', () => { + let component: CpsRadioButtonComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CpsRadioButtonComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(CpsRadioButtonComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('option', { label: 'Option A', value: 'a' }); + fixture.detectChanges(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should create with default input values', () => { + expect(component).toBeTruthy(); + expect(component.groupName).toBe(''); + expect(component.checked).toBe(false); + expect(component.groupDisabled).toBe(false); + }); + + it('should generate a non-empty, prefixed inputId', () => { + expect(component.inputId).toMatch(/^cps-radio-button-input-/); + }); + + describe('ngOnChanges accessibility guard', () => { + it('should log console.error when option has neither label nor ariaLabel', () => { + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + fixture.componentRef.setInput('option', { value: 'b' }); + fixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'CpsRadioButtonComponent: unlabeled radio button component must have an ariaLabel for accessibility.' + ); + }); + + it('should log console.error when option label and ariaLabel are blank', () => { + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + fixture.componentRef.setInput('option', { + value: 'b', + label: ' ', + ariaLabel: ' ' + }); + fixture.detectChanges(); + + expect(consoleSpy).toHaveBeenCalled(); + }); + + it('should not log console.error when option.label is set', () => { + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + fixture.componentRef.setInput('option', { value: 'b', label: 'B' }); + fixture.detectChanges(); + + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it('should not log console.error when option.ariaLabel is set', () => { + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + fixture.componentRef.setInput('option', { + value: 'b', + ariaLabel: 'Option B' + }); + fixture.detectChanges(); + + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it('should not re-log when a change other than option fires', () => { + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + fixture.componentRef.setInput('checked', true); + fixture.detectChanges(); + + expect(consoleSpy).not.toHaveBeenCalled(); + }); + }); + + describe('updateValue', () => { + it('should call preventDefault on the event', () => { + const event = { preventDefault: jest.fn() } as unknown as Event; + component.updateValue(event); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should not emit updateValueEvent when option is disabled', () => { + fixture.componentRef.setInput('option', { + label: 'A', + value: 'a', + disabled: true + }); + fixture.detectChanges(); + const spy = jest.spyOn(component.updateValueEvent, 'emit'); + + component.updateValue({ preventDefault: jest.fn() } as unknown as Event); + + expect(spy).not.toHaveBeenCalled(); + }); + + it("should emit updateValueEvent with the option's value when not disabled", () => { + const spy = jest.spyOn(component.updateValueEvent, 'emit'); + + component.updateValue({ preventDefault: jest.fn() } as unknown as Event); + + expect(spy).toHaveBeenCalledWith('a'); + }); + }); + + it('should emit blurred on onBlur', () => { + const spy = jest.spyOn(component.blurred, 'emit'); + component.onBlur(); + expect(spy).toHaveBeenCalled(); + }); + + it('should emit focused on onFocus', () => { + const spy = jest.spyOn(component.focused, 'emit'); + component.onFocus(); + expect(spy).toHaveBeenCalled(); + }); + + it('should invoke updateValue end-to-end when the rendered input changes', () => { + const spy = jest.spyOn(component.updateValueEvent, 'emit'); + const input: HTMLInputElement = fixture.nativeElement.querySelector( + 'input[type="radio"]' + ); + + input.dispatchEvent(new Event('change')); + + expect(spy).toHaveBeenCalledWith('a'); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.ts b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.ts index db6f47e7c..62d0f0e57 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-button/cps-radio-button.component.ts @@ -9,7 +9,7 @@ import { import { CpsRadioOption } from '../cps-radio-group.component'; import { CommonModule } from '@angular/common'; import { CpsTooltipDirective } from '../../../directives/cps-tooltip/cps-tooltip.directive'; -import { generateUniqueId } from '../../../utils/internal/accessibility-utils'; +import { generateUniqueId } from '../../../utils/internal/accessibility-utils/accessibility-utils'; /** * CpsRadioButtonComponent is an internal radio button component. diff --git a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.ts b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.ts index fdfb01e6b..649b4e8b5 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio-group.component.ts @@ -20,7 +20,7 @@ import { Subscription } from 'rxjs'; import { generateUniqueId, logMissingAriaLabelError -} from '../../utils/internal/accessibility-utils'; +} from '../../utils/internal/accessibility-utils/accessibility-utils'; /** * CpsRadioOption is used to define the options of the CpsRadioGroupComponent. diff --git a/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio/cps-radio.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio/cps-radio.component.spec.ts new file mode 100644 index 000000000..381b2d9fc --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-radio-group/cps-radio/cps-radio.component.spec.ts @@ -0,0 +1,97 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { CpsRadioComponent } from './cps-radio.component'; +import { CPS_RADIO_GROUP } from '../cps-radio-group.component'; +import { CpsRadioButtonComponent } from '../cps-radio-button/cps-radio-button.component'; + +describe('CpsRadioComponent', () => { + const option = { value: 'a', label: 'Option A' }; + + describe('without a radio group', () => { + let component: CpsRadioComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CpsRadioComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(CpsRadioComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('option', option); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should keep groupDisabled false when no radio group is injected', () => { + expect(component.groupDisabled).toBe(false); + }); + + it('should not throw when updateValueEvent is called without a radio group', () => { + expect(() => component.updateValueEvent('a')).not.toThrow(); + }); + + it('should render a cps-radio-button with the option passed through', () => { + const radioButton = fixture.debugElement.query( + By.directive(CpsRadioButtonComponent) + ); + expect(radioButton).toBeTruthy(); + expect(radioButton.componentInstance.option).toBe(option); + }); + }); + + describe('with a radio group', () => { + let component: CpsRadioComponent; + let fixture: ComponentFixture; + let mockGroup: { disabled: boolean; updateValueEvent: jest.Mock }; + + beforeEach(async () => { + mockGroup = { disabled: true, updateValueEvent: jest.fn() }; + + await TestBed.configureTestingModule({ + imports: [CpsRadioComponent], + providers: [{ provide: CPS_RADIO_GROUP, useValue: mockGroup }] + }).compileComponents(); + + fixture = TestBed.createComponent(CpsRadioComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('option', option); + fixture.detectChanges(); + }); + + it('should set groupDisabled from the injected group on init', () => { + expect(component.groupDisabled).toBe(true); + }); + + it('should delegate updateValueEvent to the injected group', () => { + component.updateValueEvent('a'); + expect(mockGroup.updateValueEvent).toHaveBeenCalledWith('a'); + }); + }); + + describe('with a radio group where disabled is falsy', () => { + let component: CpsRadioComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const mockGroup = { disabled: false, updateValueEvent: jest.fn() }; + + await TestBed.configureTestingModule({ + imports: [CpsRadioComponent], + providers: [{ provide: CPS_RADIO_GROUP, useValue: mockGroup }] + }).compileComponents(); + + fixture = TestBed.createComponent(CpsRadioComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('option', option); + fixture.detectChanges(); + }); + + it('should set groupDisabled to false', () => { + expect(component.groupDisabled).toBe(false); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.spec.ts index f6bf1fafb..22e7bb9cd 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.spec.ts @@ -8,9 +8,9 @@ import { import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected.pipe'; -import { CombineLabelsPipe } from '../../pipes/internal/combine-labels.pipe'; -import { LabelByValuePipe } from '../../pipes/internal/label-by-value.pipe'; +import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected/check-option-selected.pipe'; +import { CombineLabelsPipe } from '../../pipes/internal/combine-labels/combine-labels.pipe'; +import { LabelByValuePipe } from '../../pipes/internal/label-by-value/label-by-value.pipe'; import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../services/cps-root-font-size/cps-root-font-size.service'; import { CpsMenuHideReason } from '../cps-menu/cps-menu.component'; import { CpsSelectComponent } from './cps-select.component'; diff --git a/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.ts b/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.ts index db1de5200..8cc09280b 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-select/cps-select.component.ts @@ -23,11 +23,11 @@ import { Validators } from '@angular/forms'; import { Subscription } from 'rxjs'; -import { convertSize } from '../../utils/internal/size-utils'; +import { convertSize } from '../../utils/internal/size-utils/size-utils'; import { generateUniqueId, logMissingAriaLabelError -} from '../../utils/internal/accessibility-utils'; +} from '../../utils/internal/accessibility-utils/accessibility-utils'; import { CpsIconComponent, iconSizeType, @@ -36,9 +36,9 @@ import { import { CpsChipComponent } from '../cps-chip/cps-chip.component'; import { CpsProgressLinearComponent } from '../cps-progress-linear/cps-progress-linear.component'; import { CpsInfoCircleComponent } from '../cps-info-circle/cps-info-circle.component'; -import { LabelByValuePipe } from '../../pipes/internal/label-by-value.pipe'; -import { CombineLabelsPipe } from '../../pipes/internal/combine-labels.pipe'; -import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected.pipe'; +import { LabelByValuePipe } from '../../pipes/internal/label-by-value/label-by-value.pipe'; +import { CombineLabelsPipe } from '../../pipes/internal/combine-labels/combine-labels.pipe'; +import { CheckOptionSelectedPipe } from '../../pipes/internal/check-option-selected/check-option-selected.pipe'; import { isEqual } from 'lodash-es'; import { CpsTooltipPosition } from '../../directives/cps-tooltip/cps-tooltip.directive'; import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../services/cps-root-font-size/cps-root-font-size.service'; diff --git a/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.ts b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.ts index fbebcda90..75d104eb8 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.ts @@ -15,7 +15,7 @@ import { CommonModule, isPlatformBrowser } from '@angular/common'; import { Router, RouterModule } from '@angular/router'; import { CpsMenuComponent, CpsMenuItem } from '../cps-menu/cps-menu.component'; import { CpsIconComponent } from '../cps-icon/cps-icon.component'; -import { convertSize } from '../../utils/internal/size-utils'; +import { convertSize } from '../../utils/internal/size-utils/size-utils'; import { animate, state, @@ -26,7 +26,7 @@ import { import { prefersReducedMotion, REDUCED_MOTION_DURATION -} from '../../utils/internal/motion-utils'; +} from '../../utils/internal/motion-utils/motion-utils'; /** * CpsSidebarMenuItem is used to define the items of the CpsSidebarMenuComponent. diff --git a/projects/cps-ui-kit/src/lib/components/cps-switch/cps-switch.component.ts b/projects/cps-ui-kit/src/lib/components/cps-switch/cps-switch.component.ts index ad9b80937..ce42b68c1 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-switch/cps-switch.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-switch/cps-switch.component.ts @@ -14,7 +14,7 @@ import { import { ControlValueAccessor, NgControl } from '@angular/forms'; import { CpsInfoCircleComponent } from '../cps-info-circle/cps-info-circle.component'; import { CpsTooltipPosition } from '../../directives/cps-tooltip/cps-tooltip.directive'; -import { logMissingAriaLabelError } from '../../utils/internal/accessibility-utils'; +import { logMissingAriaLabelError } from '../../utils/internal/accessibility-utils/accessibility-utils'; /** * CpsSwitchComponent is a component used to toggle a boolean value. diff --git a/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab-group.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab-group.component.ts index 38d23325b..cbb9ccdc8 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab-group.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab-group.component.ts @@ -21,7 +21,7 @@ import { OnInit, Output, QueryList, - SimpleChanges, + type SimpleChanges, ViewChild, afterRenderEffect, inject, @@ -31,13 +31,13 @@ import { import { CpsIconComponent } from '../cps-icon/cps-icon.component'; import { CpsTabComponent } from './cps-tab/cps-tab.component'; import { CpsTooltipDirective } from '../../directives/cps-tooltip/cps-tooltip.directive'; -import { getCSSColor } from '../../utils/colors-utils'; -import { generateUniqueId } from '../../utils/internal/accessibility-utils'; +import { getCSSColor } from '../../utils/colors-utils/colors-utils'; +import { generateUniqueId } from '../../utils/internal/accessibility-utils/accessibility-utils'; import { CpsFocusService } from '../../services/cps-focus/cps-focus.service'; import { prefersReducedMotion, REDUCED_MOTION_DURATION -} from '../../utils/internal/motion-utils'; +} from '../../utils/internal/motion-utils/motion-utils'; import { Subscription, debounceTime, diff --git a/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab/cps-tab.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab/cps-tab.component.ts index a4811b54f..70e1852f2 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab/cps-tab.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab/cps-tab.component.ts @@ -8,7 +8,7 @@ import { TemplateRef, ViewChild } from '@angular/core'; -import { logMissingAriaLabelError } from '../../../utils/internal/accessibility-utils'; +import { logMissingAriaLabelError } from '../../../utils/internal/accessibility-utils/accessibility-utils'; /** * CpsTabComponent is a tab within a tab-group. diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component.ts b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component.ts index 1c90a144e..f22dbada8 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-table/components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component.ts @@ -12,7 +12,7 @@ import { CommonModule } from '@angular/common'; import { isEqual } from 'lodash-es'; import { CpsMenuComponent } from '../../../../cps-menu/cps-menu.component'; import { CpsButtonComponent } from '../../../../cps-button/cps-button.component'; -import { generateUniqueId } from '../../../../../utils/internal/accessibility-utils'; +import { generateUniqueId } from '../../../../../utils/internal/accessibility-utils/accessibility-utils'; /** * TableColumnVisibilityToggleComponent is an internal component that renders diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.ts b/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.ts index 85e6f5057..f79412764 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-table/cps-table.component.ts @@ -30,8 +30,8 @@ import { CpsLoaderComponent } from '../cps-loader/cps-loader.component'; import { TableRowMenuComponent } from './components/internal/table-row-menu/table-row-menu.component'; import { TableColumnVisibilityToggleComponent } from './components/internal/table-column-visibility-toggle/table-column-visibility-toggle.component'; import { CpsTableColumnSortableDirective } from './directives/cps-table-column-sortable/cps-table-column-sortable.directive'; -import { TableUnsortDirective } from './directives/internal/table-unsort.directive'; -import { convertSize } from '../../utils/internal/size-utils'; +import { TableUnsortDirective } from './directives/internal/table-unsort/table-unsort.directive'; +import { convertSize } from '../../utils/internal/size-utils/size-utils'; import { CpsTableColumnFilterDirective } from './directives/cps-table-column-filter/cps-table-column-filter.directive'; import { CpsTableDetectFilterTypePipe } from './pipes/cps-table-detect-filter-type/cps-table-detect-filter-type.pipe'; import { CpsTableColumnResizableDirective } from './directives/cps-table-column-resizable/cps-table-column-resizable.directive'; diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/directives/internal/table-unsort/table-unsort.directive.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-table/directives/internal/table-unsort/table-unsort.directive.spec.ts new file mode 100644 index 000000000..4cf362cd3 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-table/directives/internal/table-unsort/table-unsort.directive.spec.ts @@ -0,0 +1,328 @@ +import { Table } from 'primeng/table'; +import { TableUnsortDirective } from './table-unsort.directive'; + +type Row = { id: number; name: string }; + +function buildMockTable(rows: Row[] = []) { + const mock = { + // Overwritten by TableUnsortDirective's constructor; declared here so + // calls to mockTable.sort(...) below type-check. + sort: undefined as unknown as (event: { field: string }) => void, + sortMode: 'single' as 'single' | 'multiple', + defaultSortOrder: 1, + groupRowsBy: undefined as string | undefined, + groupRowsByOrder: 1, + restoringSort: false, + lazy: false, + customSort: false, + filterLocale: undefined as string | undefined, + anchorRowIndex: undefined as number | undefined, + sortFunction: { emit: jest.fn() }, + onLazyLoad: { emit: jest.fn() }, + onSort: { emit: jest.fn() }, + tableService: { onSort: jest.fn() }, + createLazyLoadMetadata: jest.fn(() => ({})), + hasFilter: jest.fn(() => false), + _filter: jest.fn(), + isStateful: jest.fn(() => false), + saveState: jest.fn(), + getGroupRowsMeta(this: any) { + return { field: this.groupRowsBy, order: this.groupRowsByOrder }; + }, + compareValuesOnSort: (v1: any, v2: any, order: number) => + order * (v1 > v2 ? 1 : v1 < v2 ? -1 : 0), + + _sortField: undefined as string | null | undefined, + get sortField() { + return this._sortField; + }, + set sortField(v) { + this._sortField = v; + }, + + _sortOrder: 1, + get sortOrder() { + return this._sortOrder; + }, + set sortOrder(v) { + this._sortOrder = v; + }, + + _multiSortMeta: undefined as + | { field: string; order: number }[] + | null + | undefined, + get multiSortMeta() { + return this._multiSortMeta; + }, + set multiSortMeta(v) { + this._multiSortMeta = v; + }, + + _value: rows, + get value() { + return this._value; + }, + set value(v) { + this._value = v; + }, + + getSortMeta(this: any, field: string) { + if (this.multiSortMeta && this.multiSortMeta.length) { + return this.multiSortMeta.find((m: any) => m.field === field) || null; + } + return null; + } + }; + + return mock; +} + +describe('TableUnsortDirective', () => { + let mockTable: ReturnType; + let directive: TableUnsortDirective; + + const freshRows = (): Row[] => [ + { id: 3, name: 'C' }, + { id: 1, name: 'A' }, + { id: 2, name: 'B' } + ]; + + beforeEach(() => { + mockTable = buildMockTable(freshRows()); + directive = new TableUnsortDirective(mockTable as unknown as Table); + }); + + it('should create and replace sort/sortSingle/sortMultiple on the injected table', () => { + expect(directive).toBeTruthy(); + expect(typeof mockTable.sort).toBe('function'); + expect(typeof (mockTable as any).sortSingle).toBe('function'); + expect(typeof (mockTable as any).sortMultiple).toBe('function'); + }); + + describe('resetDefaultSortOrder', () => { + it('should clear sortIndices', () => { + mockTable.sort({ field: 'name' }); + expect(directive.sortIndices.length).toBeGreaterThan(0); + + directive.resetDefaultSortOrder(); + + expect(directive.sortIndices).toEqual([]); + }); + }); + + describe('single-sort mode', () => { + it('should sort ascending on first click', () => { + mockTable.sort({ field: 'name' }); + + expect(mockTable.value.map((r) => r.name)).toEqual(['A', 'B', 'C']); + expect(mockTable.sortField).toBe('name'); + expect(mockTable.sortOrder).toBe(mockTable.defaultSortOrder); + expect(directive.sortIndices.length).toBe(3); + }); + + it('should toggle to descending on second click of the same field', () => { + mockTable.sort({ field: 'name' }); + mockTable.sort({ field: 'name' }); + + expect(mockTable.value.map((r) => r.name)).toEqual(['C', 'B', 'A']); + expect(mockTable.sortOrder).toBe(-1); + }); + + it('should restore the original order on a third click of the same field (unsort)', () => { + mockTable.sort({ field: 'name' }); + mockTable.sort({ field: 'name' }); + mockTable.sort({ field: 'name' }); + + expect(mockTable.value).toEqual(freshRows()); + expect(mockTable.sortField).toBeNull(); + expect(mockTable.sortOrder).toBe(mockTable.defaultSortOrder); + expect(directive.sortIndices).toEqual([]); + }); + + it('should reset order to defaultSortOrder (not toggle) when sorting a different field after unsort', () => { + mockTable.sort({ field: 'name' }); + mockTable.sort({ field: 'name' }); + mockTable.sort({ field: 'name' }); + + mockTable.sort({ field: 'id' }); + + expect(mockTable.sortField).toBe('id'); + expect(mockTable.sortOrder).toBe(mockTable.defaultSortOrder); + expect(mockTable.value.map((r) => r.id)).toEqual([1, 2, 3]); + }); + + it('should call saveState when the table is stateful', () => { + mockTable.isStateful.mockReturnValue(true); + mockTable.sort({ field: 'name' }); + expect(mockTable.saveState).toHaveBeenCalled(); + }); + + it('should not call saveState when the table is not stateful', () => { + mockTable.isStateful.mockReturnValue(false); + mockTable.sort({ field: 'name' }); + expect(mockTable.saveState).not.toHaveBeenCalled(); + }); + + it('should reset anchorRowIndex to 0 after every sort', () => { + mockTable.anchorRowIndex = 5; + mockTable.sort({ field: 'name' }); + expect(mockTable.anchorRowIndex).toBe(0); + }); + + it('should call _filter when the table has an active filter', () => { + mockTable.hasFilter.mockReturnValue(true); + mockTable.sort({ field: 'name' }); + expect(mockTable._filter).toHaveBeenCalled(); + }); + + it('should emit sortFunction and skip internal sorting when customSort is true', () => { + mockTable.customSort = true; + const original = [...mockTable.value]; + + mockTable.sort({ field: 'name' }); + + expect(mockTable.sortFunction.emit).toHaveBeenCalledWith({ + data: mockTable.value, + mode: 'single', + field: 'name', + order: mockTable.defaultSortOrder + }); + expect(mockTable.value).toEqual(original); + }); + + it('should emit onLazyLoad and skip internal sorting when lazy is true', () => { + mockTable.lazy = true; + const original = [...mockTable.value]; + + mockTable.sort({ field: 'name' }); + + expect(mockTable.onLazyLoad.emit).toHaveBeenCalled(); + expect(mockTable.value).toEqual(original); + }); + + it('should emit onSort and tableService.onSort with the expected SortMeta', () => { + mockTable.sort({ field: 'name' }); + + expect(mockTable.onSort.emit).toHaveBeenCalledWith({ + field: 'name', + order: mockTable.defaultSortOrder + }); + expect(mockTable.tableService.onSort).toHaveBeenCalledWith({ + field: 'name', + order: mockTable.defaultSortOrder + }); + }); + }); + + describe('sortSingle grouped-rows delegation', () => { + it('should delegate to sortMultiple via a synthesized _multiSortMeta when groupRowsBy differs from sortField', () => { + mockTable.groupRowsBy = 'category'; + mockTable.groupRowsByOrder = 1; + mockTable.sortField = 'name'; + mockTable.sortOrder = 1; + + (mockTable as any).sortSingle(); + + expect(mockTable.multiSortMeta).toEqual([ + { field: 'category', order: 1 }, + { field: 'name', order: 1 } + ]); + }); + }); + + describe('multiple-sort mode', () => { + beforeEach(() => { + mockTable.sortMode = 'multiple'; + }); + + it('should push a new entry onto multiSortMeta for a fresh field', () => { + mockTable.sort({ field: 'name' }); + + expect(mockTable.multiSortMeta).toEqual([ + { field: 'name', order: mockTable.defaultSortOrder } + ]); + expect(mockTable.value.map((r) => r.name)).toEqual(['A', 'B', 'C']); + }); + + it('should flip an existing entry from order 1 to -1 on a second click', () => { + mockTable.sort({ field: 'name' }); + mockTable.sort({ field: 'name' }); + + expect(mockTable.multiSortMeta).toEqual([{ field: 'name', order: -1 }]); + expect(mockTable.value.map((r) => r.name)).toEqual(['C', 'B', 'A']); + }); + + it('should remove the entry and restore original order on a third click (unsort)', () => { + mockTable.sort({ field: 'name' }); + mockTable.sort({ field: 'name' }); + mockTable.sort({ field: 'name' }); + + expect(mockTable.multiSortMeta).toEqual([]); + expect(mockTable.value).toEqual(freshRows()); + }); + + it('should sort by multiple fields, using later fields as tie-breakers', () => { + const rows = [ + { id: 1, a: 'X', b: 2 }, + { id: 2, a: 'Y', b: 1 }, + { id: 3, a: 'X', b: 1 } + ]; + mockTable = buildMockTable(rows as any); + mockTable.sortMode = 'multiple'; + directive = new TableUnsortDirective(mockTable as unknown as Table); + + mockTable.sort({ field: 'a' }); + mockTable.sort({ field: 'b' }); + + expect((mockTable.value as any[]).map((r) => r.id)).toEqual([3, 1, 2]); + }); + + it('should emit sortFunction and skip internal sorting when customSort is true', () => { + mockTable.customSort = true; + const original = [...mockTable.value]; + + mockTable.sort({ field: 'name' }); + + expect(mockTable.sortFunction.emit).toHaveBeenCalledWith({ + data: mockTable.value, + mode: 'multiple', + multiSortMeta: mockTable.multiSortMeta + }); + expect(mockTable.value).toEqual(original); + }); + + it('should emit onLazyLoad and skip internal sorting when lazy is true', () => { + mockTable.lazy = true; + const original = [...mockTable.value]; + + mockTable.sort({ field: 'name' }); + + expect(mockTable.onLazyLoad.emit).toHaveBeenCalled(); + expect(mockTable.value).toEqual(original); + }); + + it('should prepend the group-rows meta entry when groupRowsBy is set', () => { + mockTable.groupRowsBy = 'category'; + mockTable.groupRowsByOrder = 1; + + (mockTable as any).sortMultiple(); + + expect(mockTable.multiSortMeta).toEqual([ + { field: 'category', order: 1 } + ]); + }); + + it('should not duplicate the group-rows meta entry when it is already first', () => { + mockTable.groupRowsBy = 'category'; + mockTable.groupRowsByOrder = 1; + mockTable.multiSortMeta = [{ field: 'category', order: 1 }]; + + (mockTable as any).sortMultiple(); + + expect(mockTable.multiSortMeta).toEqual([ + { field: 'category', order: 1 } + ]); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-table/directives/internal/table-unsort.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-table/directives/internal/table-unsort/table-unsort.directive.ts similarity index 100% rename from projects/cps-ui-kit/src/lib/components/cps-table/directives/internal/table-unsort.directive.ts rename to projects/cps-ui-kit/src/lib/components/cps-table/directives/internal/table-unsort/table-unsort.directive.ts diff --git a/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.ts index 915ceed15..401a0b7bf 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tag/cps-tag.component.ts @@ -11,7 +11,7 @@ import { Self, type SimpleChanges } from '@angular/core'; -import { getCSSColor } from '../../utils/colors-utils'; +import { getCSSColor } from '../../utils/colors-utils/colors-utils'; import { ControlValueAccessor, NgControl } from '@angular/forms'; /** diff --git a/projects/cps-ui-kit/src/lib/components/cps-textarea/cps-textarea.component.ts b/projects/cps-ui-kit/src/lib/components/cps-textarea/cps-textarea.component.ts index 2dcec09c2..2ff50f5c7 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-textarea/cps-textarea.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-textarea/cps-textarea.component.ts @@ -20,7 +20,10 @@ import { import { CommonModule, isPlatformBrowser } from '@angular/common'; import { ControlValueAccessor, NgControl, Validators } from '@angular/forms'; import { Subscription } from 'rxjs'; -import { convertSize, parseSize } from '../../utils/internal/size-utils'; +import { + convertSize, + parseSize +} from '../../utils/internal/size-utils/size-utils'; import { CpsIconComponent } from '../cps-icon/cps-icon.component'; import { CpsInfoCircleComponent } from '../cps-info-circle/cps-info-circle.component'; import { CpsTooltipPosition } from '../../directives/cps-tooltip/cps-tooltip.directive'; @@ -28,7 +31,7 @@ import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../services/cps-root-font-size/cp import { generateUniqueId, logMissingAriaLabelError -} from '../../utils/internal/accessibility-utils'; +} from '../../utils/internal/accessibility-utils/accessibility-utils'; const RESIZE_STEP_REM = 1.5; diff --git a/projects/cps-ui-kit/src/lib/components/cps-timepicker/cps-timepicker.component.ts b/projects/cps-ui-kit/src/lib/components/cps-timepicker/cps-timepicker.component.ts index c2e7203e4..fff4403f6 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-timepicker/cps-timepicker.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-timepicker/cps-timepicker.component.ts @@ -24,7 +24,7 @@ import { CpsInfoCircleComponent } from '../cps-info-circle/cps-info-circle.compo import { generateUniqueId, logMissingAriaLabelError -} from '../../utils/internal/accessibility-utils'; +} from '../../utils/internal/accessibility-utils/accessibility-utils'; /** * CpsTime is used to define the time format. diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.spec.ts index 9cf58e5da..71996dbd0 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.spec.ts @@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { CombineLabelsPipe } from '../../pipes/internal/combine-labels.pipe'; +import { CombineLabelsPipe } from '../../pipes/internal/combine-labels/combine-labels.pipe'; import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../services/cps-root-font-size/cps-root-font-size.service'; import { CpsTreeSelectComponent } from './cps-tree-select.component'; diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.ts index e1c8a400e..e3ddca74c 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-select/cps-tree-select.component.ts @@ -4,7 +4,7 @@ import { CpsIconComponent } from '../cps-icon/cps-icon.component'; import { CpsChipComponent } from '../cps-chip/cps-chip.component'; import { CpsProgressLinearComponent } from '../cps-progress-linear/cps-progress-linear.component'; import { CpsInfoCircleComponent } from '../cps-info-circle/cps-info-circle.component'; -import { CombineLabelsPipe } from '../../pipes/internal/combine-labels.pipe'; +import { CombineLabelsPipe } from '../../pipes/internal/combine-labels/combine-labels.pipe'; import { TreeModule } from 'primeng/tree'; import { CpsMenuComponent } from '../cps-menu/cps-menu.component'; import { CpsBaseTreeDropdownComponent } from '../internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component'; diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.ts index d1efac84e..04d9302c1 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/cps-tree-table.component.ts @@ -20,7 +20,7 @@ import { Output, Renderer2, RendererStyleFlags2, - SimpleChanges, + type SimpleChanges, TemplateRef, ViewChild } from '@angular/core'; @@ -35,7 +35,7 @@ import { TreeTableStyle } from 'primeng/treetable'; import { Subscription, fromEvent } from 'rxjs'; -import { convertSize } from '../../utils/internal/size-utils'; +import { convertSize } from '../../utils/internal/size-utils/size-utils'; import { CpsButtonComponent } from '../cps-button/cps-button.component'; import { CpsIconComponent } from '../cps-icon/cps-icon.component'; import { CpsInputComponent } from '../cps-input/cps-input.component'; @@ -50,7 +50,7 @@ import { CpsTreeTableColumnSortableDirective } from './directives/cps-tree-table import { CpsTreeTableHeaderSelectableDirective } from './directives/cps-tree-table-header-selectable/cps-tree-table-header-selectable.directive'; import { CpsTreeTableRowSelectableDirective } from './directives/cps-tree-table-row-selectable/cps-tree-table-row-selectable.directive'; import { CpsTreetableRowTogglerDirective } from './directives/cps-tree-table-row-toggler/cps-tree-table-row-toggler.directive'; -import { TreeTableUnsortDirective } from './directives/internal/tree-table-unsort.directive'; +import { TreeTableUnsortDirective } from './directives/internal/tree-table-unsort/tree-table-unsort.directive'; import { CpsTreeTableDetectFilterTypePipe } from './pipes/cps-tree-table-detect-filter-type/cps-tree-table-detect-filter-type.pipe'; import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../services/cps-root-font-size/cps-root-font-size.service'; diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/internal/tree-table-unsort/tree-table-unsort.directive.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/internal/tree-table-unsort/tree-table-unsort.directive.spec.ts new file mode 100644 index 000000000..fcd47d3b7 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/internal/tree-table-unsort/tree-table-unsort.directive.spec.ts @@ -0,0 +1,706 @@ +import { TreeTable } from 'primeng/treetable'; +import { TreeTableUnsortDirective } from './tree-table-unsort.directive'; + +type Node = { + data: Record; + children?: Node[]; + expanded?: boolean; + partialSelected?: boolean; + _defaultSortOrder?: number; + _idx?: number; +}; + +const makeNode = ( + id: number, + name: string, + _defaultSortOrder: number, + children?: Node[] +): Node => ({ data: { id, name }, _defaultSortOrder, children }); + +const freshNodes = (): Node[] => [ + makeNode(3, 'C', 0), + makeNode(1, 'A', 1), + makeNode(2, 'B', 2) +]; + +function buildMockTreeTable(nodes: Node[] = []) { + const mock = { + // Overwritten by TreeTableUnsortDirective's constructor; declared here + // so calls to mockTreeTable.sort(...) below type-check. + sort: undefined as unknown as (event: { field: string }) => void, + sortMode: 'single' as 'single' | 'multiple', + defaultSortOrder: 1, + lazy: false, + customSort: false, + scrollable: false, + paginator: false, + totalRecords: 0, + first: 5, + filters: {} as Record, + filterMode: undefined as 'strict' | undefined, + filterService: { filters: {} as Record }, + columns: undefined as any, + globalFilterFields: undefined as any, + filteredNodes: null as Node[] | null, + value: nodes as Node[] | undefined, + + sortFunction: { emit: jest.fn() }, + onLazyLoad: { emit: jest.fn() }, + onSort: { emit: jest.fn() }, + onFilter: { emit: jest.fn() }, + tableService: { onSort: jest.fn(), onUIUpdate: jest.fn() }, + createLazyLoadMetadata: jest.fn(() => ({})), + hasFilter: jest.fn(() => false), + isFilterMatched: jest.fn((_node?: any, _params?: any) => false), + updateSerializedValue: jest.fn(), + resetScrollTop: jest.fn(), + + _sortField: undefined as string | null | undefined, + get sortField() { + return this._sortField; + }, + set sortField(v) { + this._sortField = v; + }, + + _sortOrder: 1, + get sortOrder() { + return this._sortOrder; + }, + set sortOrder(v) { + this._sortOrder = v; + }, + + _multiSortMeta: undefined as + | { field: string; order: number }[] + | null + | undefined, + get multiSortMeta() { + return this._multiSortMeta; + }, + set multiSortMeta(v) { + this._multiSortMeta = v; + }, + + getSortMeta(field: string) { + if (this.multiSortMeta && this.multiSortMeta.length) { + return this.multiSortMeta.find((m) => m.field === field) || null; + } + return null; + }, + + // Real (unpatched) TreeTable prototype behavior the directive relies on. + sortSingle() { + if (this.sortField && this.sortOrder) { + if (this.lazy) { + this.onLazyLoad.emit(this.createLazyLoadMetadata()); + } else if (this.value) { + (this as any).sortNodes(this.value); + if (this.hasFilter()) (this as any)._filter(); + } + const sortMeta = { field: this.sortField, order: this.sortOrder }; + this.onSort.emit(sortMeta); + this.tableService.onSort(sortMeta); + this.updateSerializedValue(); + } + }, + sortMultiple() { + if (this.multiSortMeta) { + if (this.lazy) { + this.onLazyLoad.emit(this.createLazyLoadMetadata()); + } else if (this.value) { + (this as any).sortMultipleNodes(this.value); + if (this.hasFilter()) (this as any)._filter(); + } + this.onSort.emit({ multisortmeta: this.multiSortMeta }); + this.updateSerializedValue(); + this.tableService.onSort(this.multiSortMeta); + } + } + }; + + return mock; +} + +describe('TreeTableUnsortDirective', () => { + let mockTreeTable: ReturnType; + let directive: TreeTableUnsortDirective; + + beforeEach(() => { + mockTreeTable = buildMockTreeTable(freshNodes()); + directive = new TreeTableUnsortDirective( + mockTreeTable as unknown as TreeTable + ); + }); + + it('should create and patch sort, sortNodes, sortMultipleNodes, multisortField, _filter and findFilteredNodes', () => { + expect(directive).toBeTruthy(); + expect(typeof mockTreeTable.sort).toBe('function'); + expect(typeof (mockTreeTable as any).sortNodes).toBe('function'); + expect(typeof (mockTreeTable as any).sortMultipleNodes).toBe('function'); + expect(typeof (mockTreeTable as any).multisortField).toBe('function'); + expect(typeof (mockTreeTable as any)._filter).toBe('function'); + expect(typeof (mockTreeTable as any).findFilteredNodes).toBe('function'); + }); + + describe('sort() - single mode', () => { + it('should sort ascending on first click', () => { + mockTreeTable.sort({ field: 'name' }); + + expect(mockTreeTable.value!.map((n) => n.data.name)).toEqual([ + 'A', + 'B', + 'C' + ]); + expect(mockTreeTable.sortField).toBe('name'); + expect(mockTreeTable.sortOrder).toBe(mockTreeTable.defaultSortOrder); + }); + + it('should toggle to descending on second click of the same field', () => { + mockTreeTable.sort({ field: 'name' }); + mockTreeTable.sort({ field: 'name' }); + + expect(mockTreeTable.value!.map((n) => n.data.name)).toEqual([ + 'C', + 'B', + 'A' + ]); + expect(mockTreeTable.sortOrder).toBe(-1); + }); + + it('should rewrite the field to the _defaultSortOrder sentinel and restore original order on a third click', () => { + mockTreeTable.sort({ field: 'name' }); + mockTreeTable.sort({ field: 'name' }); + mockTreeTable.sort({ field: 'name' }); + + expect(mockTreeTable.sortField).toBe('_defaultSortOrder'); + expect(mockTreeTable.sortOrder).toBe(mockTreeTable.defaultSortOrder); + expect(mockTreeTable.value!.map((n) => n.data.id)).toEqual([3, 1, 2]); + }); + }); + + describe('sort() - multiple mode', () => { + beforeEach(() => { + mockTreeTable.sortMode = 'multiple'; + }); + + it('should push a new entry onto multiSortMeta for a fresh field and sort ascending', () => { + mockTreeTable.sort({ field: 'name' }); + + expect(mockTreeTable.multiSortMeta).toEqual([ + { field: 'name', order: mockTreeTable.defaultSortOrder } + ]); + expect(mockTreeTable.value!.map((n) => n.data.name)).toEqual([ + 'A', + 'B', + 'C' + ]); + }); + + it('should flip an existing entry from order 1 to -1 on a second click', () => { + mockTreeTable.sort({ field: 'name' }); + mockTreeTable.sort({ field: 'name' }); + + expect(mockTreeTable.multiSortMeta).toEqual([ + { field: 'name', order: -1 } + ]); + expect(mockTreeTable.value!.map((n) => n.data.name)).toEqual([ + 'C', + 'B', + 'A' + ]); + }); + + it('should remove the entry and restore original order on a third click (unsort)', () => { + mockTreeTable.sort({ field: 'name' }); + mockTreeTable.sort({ field: 'name' }); + mockTreeTable.sort({ field: 'name' }); + + expect(mockTreeTable.multiSortMeta).toEqual([]); + expect(mockTreeTable.value!.map((n) => n.data.id)).toEqual([3, 1, 2]); + }); + + it('should sort by multiple fields, using later fields as tie-breakers', () => { + const nodes: Node[] = [ + { data: { id: 1, a: 'X', b: 2 }, _defaultSortOrder: 0 }, + { data: { id: 2, a: 'Y', b: 1 }, _defaultSortOrder: 1 }, + { data: { id: 3, a: 'X', b: 1 }, _defaultSortOrder: 2 } + ]; + mockTreeTable = buildMockTreeTable(nodes); + mockTreeTable.sortMode = 'multiple'; + directive = new TreeTableUnsortDirective( + mockTreeTable as unknown as TreeTable + ); + + mockTreeTable.sort({ field: 'a' }); + mockTreeTable.sort({ field: 'b' }); + + expect(mockTreeTable.value!.map((n) => n.data.id)).toEqual([3, 1, 2]); + }); + }); + + describe('sortNodes', () => { + it('should be a no-op for an empty or undefined array', () => { + expect(() => (mockTreeTable as any).sortNodes([])).not.toThrow(); + expect(() => (mockTreeTable as any).sortNodes(undefined)).not.toThrow(); + }); + + it('should call _syncNodesParams(filteredNodes, nodes) before sorting', () => { + const nodes = freshNodes(); + mockTreeTable.filteredNodes = [{ ...nodes[0], _idx: 0, expanded: true }]; + + (mockTreeTable as any).sortNodes(nodes); + + const restored = nodes.find((n) => n.data.id === 3); + expect(restored?.expanded).toBe(true); + }); + + it('should sort strings using localeCompare with numeric option', () => { + const nodes: Node[] = [ + { data: { label: 'item10' }, _defaultSortOrder: 0 }, + { data: { label: 'item2' }, _defaultSortOrder: 1 } + ]; + mockTreeTable.sortField = 'label'; + mockTreeTable.sortOrder = 1; + + (mockTreeTable as any).sortNodes(nodes); + + expect(nodes.map((n) => n.data.label)).toEqual(['item2', 'item10']); + }); + + it('should sort numeric field values with ', () => { + const nodes: Node[] = [ + { data: { count: 30 }, _defaultSortOrder: 0 }, + { data: { count: 10 }, _defaultSortOrder: 1 }, + { data: { count: 20 }, _defaultSortOrder: 2 } + ]; + mockTreeTable.sortField = 'count'; + mockTreeTable.sortOrder = 1; + + (mockTreeTable as any).sortNodes(nodes); + + expect(nodes.map((n) => n.data.count)).toEqual([10, 20, 30]); + }); + + it('should sort null/undefined field values to a consistent position (before non-null values, ascending)', () => { + const nodes: Node[] = [ + { data: { label: 'B' }, _defaultSortOrder: 0 }, + { data: { label: null }, _defaultSortOrder: 1 }, + { data: { label: 'A' }, _defaultSortOrder: 2 } + ]; + mockTreeTable.sortField = 'label'; + mockTreeTable.sortOrder = 1; + + (mockTreeTable as any).sortNodes(nodes); + + expect(nodes.map((n) => n.data.label)).toEqual([null, 'A', 'B']); + }); + + it('should sort by _defaultSortOrder when sortField is the unsort sentinel', () => { + const nodes = freshNodes(); + mockTreeTable.sortField = '_defaultSortOrder'; + mockTreeTable.sortOrder = 1; + + (mockTreeTable as any).sortNodes(nodes); + + expect(nodes.map((n) => n.data.id)).toEqual([3, 1, 2]); + }); + + it('should multiply the comparison by sortOrder for descending sort', () => { + const nodes = freshNodes(); + mockTreeTable.sortField = 'name'; + mockTreeTable.sortOrder = -1; + + (mockTreeTable as any).sortNodes(nodes); + + expect(nodes.map((n) => n.data.name)).toEqual(['C', 'B', 'A']); + }); + + it('should recurse into node.children', () => { + const child1 = makeNode(20, 'B', 0); + const child2 = makeNode(10, 'A', 1); + const nodes: Node[] = [ + { data: { id: 1, name: 'Parent' }, children: [child1, child2] } + ]; + mockTreeTable.sortField = 'name'; + mockTreeTable.sortOrder = 1; + + (mockTreeTable as any).sortNodes(nodes); + + expect(nodes[0].children!.map((n) => n.data.name)).toEqual(['A', 'B']); + }); + + it('should emit sortFunction and skip default sorting when customSort is true', () => { + const nodes = freshNodes(); + const original = [...nodes]; + mockTreeTable.customSort = true; + mockTreeTable.sortField = 'name'; + mockTreeTable.sortOrder = 1; + + (mockTreeTable as any).sortNodes(nodes); + + expect(mockTreeTable.sortFunction.emit).toHaveBeenCalledWith({ + data: nodes, + mode: 'single', + field: 'name', + order: 1 + }); + expect(nodes).toEqual(original); + }); + }); + + describe('sortMultipleNodes', () => { + it('should be a no-op for an empty or undefined array', () => { + expect(() => (mockTreeTable as any).sortMultipleNodes([])).not.toThrow(); + expect(() => + (mockTreeTable as any).sortMultipleNodes(undefined) + ).not.toThrow(); + }); + + it('should call _syncNodesParams(filteredNodes, nodes) before sorting', () => { + const nodes = freshNodes(); + mockTreeTable.filteredNodes = [{ ...nodes[0], _idx: 0, expanded: true }]; + mockTreeTable.multiSortMeta = [{ field: 'name', order: 1 }]; + + (mockTreeTable as any).sortMultipleNodes(nodes); + + const restored = nodes.find((n) => n.data.id === 3); + expect(restored?.expanded).toBe(true); + }); + + it('should recurse into node.children', () => { + const child1 = makeNode(20, 'B', 0); + const child2 = makeNode(10, 'A', 1); + const nodes: Node[] = [ + { data: { id: 1, name: 'Parent' }, children: [child1, child2] } + ]; + mockTreeTable.multiSortMeta = [{ field: 'name', order: 1 }]; + + (mockTreeTable as any).sortMultipleNodes(nodes); + + expect(nodes[0].children!.map((n) => n.data.name)).toEqual(['A', 'B']); + }); + + it('should emit sortFunction and skip default sorting when customSort is true', () => { + const nodes = freshNodes(); + const original = [...nodes]; + mockTreeTable.customSort = true; + mockTreeTable.multiSortMeta = [{ field: 'name', order: 1 }]; + + (mockTreeTable as any).sortMultipleNodes(nodes); + + expect(mockTreeTable.sortFunction.emit).toHaveBeenCalledWith({ + data: mockTreeTable.value, + mode: 'single', + multiSortMeta: mockTreeTable.multiSortMeta + }); + expect(nodes).toEqual(original); + }); + }); + + describe('multisortField', () => { + it('should return 0 when pTreeTable.multiSortMeta is empty, even if a non-empty meta array is passed', () => { + mockTreeTable.multiSortMeta = undefined; + const n1 = makeNode(1, 'A', 0); + const n2 = makeNode(2, 'B', 1); + + const result = (mockTreeTable as any).multisortField( + n1, + n2, + [{ field: 'name', order: 1 }], + 0 + ); + + expect(result).toBe(0); + }); + + it('should return 0 when multiSortMeta[index] is empty', () => { + mockTreeTable.multiSortMeta = [{ field: 'name', order: 1 }]; + const n1 = makeNode(1, 'A', 0); + const n2 = makeNode(2, 'B', 1); + + const result = (mockTreeTable as any).multisortField( + n1, + n2, + [{ field: 'name', order: 1 }], + 5 + ); + + expect(result).toBe(0); + }); + + it('should compare via _defaultSortOrder for the unsort sentinel field', () => { + const meta = [{ field: '_defaultSortOrder', order: 1 }]; + mockTreeTable.multiSortMeta = meta; + const n1 = makeNode(1, 'A', 5); + const n2 = makeNode(2, 'B', 2); + + const result = (mockTreeTable as any).multisortField(n1, n2, meta, 0); + + expect(result).toBeGreaterThan(0); + }); + + it('should compare strings via localeCompare multiplied by order', () => { + const meta = [{ field: 'name', order: -1 }]; + mockTreeTable.multiSortMeta = meta; + const n1 = makeNode(1, 'A', 0); + const n2 = makeNode(2, 'B', 1); + + const result = (mockTreeTable as any).multisortField(n1, n2, meta, 0); + + expect(result).toBeGreaterThan(0); + }); + + it('should compare non-string values with multiplied by order', () => { + const meta = [{ field: 'count', order: 1 }]; + mockTreeTable.multiSortMeta = meta; + const n1 = { data: { count: 5 } } as Node; + const n2 = { data: { count: 10 } } as Node; + + const result = (mockTreeTable as any).multisortField(n1, n2, meta, 0); + + expect(result).toBe(-1); + }); + + it('should recurse to index + 1 on a tie', () => { + const meta = [ + { field: 'group', order: 1 }, + { field: 'name', order: 1 } + ]; + mockTreeTable.multiSortMeta = meta; + const n1 = { data: { group: 'X', name: 'B' } } as Node; + const n2 = { data: { group: 'X', name: 'A' } } as Node; + + const result = (mockTreeTable as any).multisortField(n1, n2, meta, 0); + + expect(result).toBeGreaterThan(0); + }); + + it('should return 0 on a tie at the last meta entry', () => { + const meta = [{ field: 'group', order: 1 }]; + mockTreeTable.multiSortMeta = meta; + const n1 = { data: { group: 'X' } } as Node; + const n2 = { data: { group: 'X' } } as Node; + + const result = (mockTreeTable as any).multisortField(n1, n2, meta, 0); + + expect(result).toBe(0); + }); + + it('should sort null/undefined values to a consistent end', () => { + const meta = [{ field: 'count', order: 1 }]; + mockTreeTable.multiSortMeta = meta; + const n1 = { data: { count: null } } as Node; + const n2 = { data: { count: 5 } } as Node; + + const result = (mockTreeTable as any).multisortField(n1, n2, meta, 0); + + expect(result).toBe(-1); + }); + }); + + describe('_filter', () => { + it('should emit onLazyLoad and skip local filtering when lazy is true', () => { + mockTreeTable.lazy = true; + + (mockTreeTable as any)._filter(); + + expect(mockTreeTable.onLazyLoad.emit).toHaveBeenCalled(); + }); + + it('should return early without emitting when value is falsy', () => { + mockTreeTable.value = undefined; + + (mockTreeTable as any)._filter(); + + expect(mockTreeTable.onFilter.emit).not.toHaveBeenCalled(); + }); + + it('should clear filteredNodes and set totalRecords when there is no active filter and paginator is on', () => { + mockTreeTable.hasFilter.mockReturnValue(false); + mockTreeTable.paginator = true; + + (mockTreeTable as any)._filter(); + + expect(mockTreeTable.filteredNodes).toBeNull(); + expect(mockTreeTable.totalRecords).toBe(mockTreeTable.value!.length); + }); + + it('should throw when a global filter is configured without columns or globalFilterFields', () => { + mockTreeTable.hasFilter.mockReturnValue(true); + mockTreeTable.filters = { global: { value: 'x', matchMode: 'contains' } }; + mockTreeTable.columns = undefined; + mockTreeTable.globalFilterFields = undefined; + + expect(() => (mockTreeTable as any)._filter()).toThrow( + 'Global filtering requires dynamic columns or globalFilterFields to be defined.' + ); + }); + + it('should tag matching nodes with _idx when a local filter is active', () => { + mockTreeTable.hasFilter.mockReturnValue(true); + mockTreeTable.filters = { name: { value: 'A', matchMode: 'contains' } }; + mockTreeTable.isFilterMatched.mockImplementation( + (node: any) => node.data.name === 'A' + ); + + (mockTreeTable as any)._filter(); + + expect(mockTreeTable.filteredNodes).toHaveLength(1); + expect(mockTreeTable.filteredNodes![0].data.name).toBe('A'); + expect((mockTreeTable.filteredNodes![0] as any)._idx).toBe(1); + }); + + it('should not call findFilteredNodes when isFilterMatched already matched in non-strict mode', () => { + mockTreeTable.hasFilter.mockReturnValue(true); + mockTreeTable.filterMode = undefined; + mockTreeTable.filters = { name: { value: 'A', matchMode: 'contains' } }; + mockTreeTable.isFilterMatched.mockReturnValue(true); + const findFilteredNodesSpy = jest.fn().mockReturnValue(false); + (mockTreeTable as any).findFilteredNodes = findFilteredNodesSpy; + + (mockTreeTable as any)._filter(); + + expect(findFilteredNodesSpy).not.toHaveBeenCalled(); + }); + + it('should not call isFilterMatched when findFilteredNodes already matched in strict mode', () => { + mockTreeTable.hasFilter.mockReturnValue(true); + mockTreeTable.filterMode = 'strict'; + mockTreeTable.filters = { name: { value: 'A', matchMode: 'contains' } }; + (mockTreeTable as any).findFilteredNodes = jest + .fn() + .mockReturnValue(true); + + (mockTreeTable as any)._filter(); + + expect(mockTreeTable.isFilterMatched).not.toHaveBeenCalled(); + }); + + it('should always reset first, emit onFilter and update the table service and serialized value', () => { + mockTreeTable.first = 7; + + (mockTreeTable as any)._filter(); + + expect(mockTreeTable.first).toBe(0); + expect(mockTreeTable.onFilter.emit).toHaveBeenCalledWith({ + filters: mockTreeTable.filters, + filteredValue: mockTreeTable.value + }); + expect(mockTreeTable.tableService.onUIUpdate).toHaveBeenCalledWith( + mockTreeTable.value + ); + expect(mockTreeTable.updateSerializedValue).toHaveBeenCalled(); + }); + + it('should call resetScrollTop when scrollable is true', () => { + mockTreeTable.scrollable = true; + + (mockTreeTable as any)._filter(); + + expect(mockTreeTable.resetScrollTop).toHaveBeenCalled(); + }); + + it('should not call resetScrollTop when scrollable is false', () => { + mockTreeTable.scrollable = false; + + (mockTreeTable as any)._filter(); + + expect(mockTreeTable.resetScrollTop).not.toHaveBeenCalled(); + }); + }); + + describe('findFilteredNodes', () => { + it('should return undefined for a falsy node', () => { + const result = (mockTreeTable as any).findFilteredNodes(null, {}); + expect(result).toBeUndefined(); + }); + + it('should return true and replace children with only the matching ones, tagged with _idx', () => { + mockTreeTable.isFilterMatched.mockImplementation( + (node: any) => node.data.name === 'Match' + ); + const node: Node = { + data: { id: 1 }, + children: [{ data: { name: 'NoMatch' } }, { data: { name: 'Match' } }] + }; + + const result = (mockTreeTable as any).findFilteredNodes(node, {}); + + expect(result).toBe(true); + expect(node.children).toHaveLength(1); + expect(node.children![0].data.name).toBe('Match'); + expect((node.children![0] as any)._idx).toBe(1); + }); + + it('should return undefined and empty node.children when no children match', () => { + mockTreeTable.isFilterMatched.mockReturnValue(false); + const node: Node = { + data: { id: 1 }, + children: [{ data: { name: 'NoMatch' } }] + }; + + const result = (mockTreeTable as any).findFilteredNodes(node, {}); + + expect(result).toBeUndefined(); + expect(node.children).toEqual([]); + }); + + it('should leave node.children untouched when the node has no children property', () => { + const node: Node = { data: { id: 1 } }; + + const result = (mockTreeTable as any).findFilteredNodes(node, {}); + + expect(result).toBeUndefined(); + expect(node.children).toBeUndefined(); + }); + }); + + describe('_syncNodesParams', () => { + it('should be a no-op when from or to is falsy', () => { + expect(() => + (directive as any)._syncNodesParams(null, freshNodes()) + ).not.toThrow(); + expect(() => + (directive as any)._syncNodesParams(freshNodes(), null) + ).not.toThrow(); + }); + + it('should copy expanded/partialSelected from a matched source node and delete _idx from it', () => { + const to = freshNodes(); + const from: Node[] = [ + { ...to[0], _idx: 0, expanded: true, partialSelected: true } + ]; + + (directive as any)._syncNodesParams(from, to); + + expect(to[0].expanded).toBe(true); + expect(to[0].partialSelected).toBe(true); + expect(from[0]._idx).toBeUndefined(); + }); + + it('should recurse into children when both source and target have them', () => { + const toChild = makeNode(10, 'Child', 0); + const to: Node[] = [ + { data: { id: 1, name: 'Parent' }, children: [toChild] } + ]; + const fromChild = { ...toChild, _idx: 0, expanded: true }; + const from: Node[] = [ + { data: { id: 1, name: 'Parent' }, _idx: 0, children: [fromChild] } + ]; + + (directive as any)._syncNodesParams(from, to); + + expect(to[0].children![0].expanded).toBe(true); + }); + + it('should skip entries in from without a numeric _idx', () => { + const to = freshNodes(); + const from: Node[] = [{ ...to[0], expanded: true }]; + + (directive as any)._syncNodesParams(from, to); + + expect(to[0].expanded).toBeUndefined(); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/internal/tree-table-unsort.directive.ts b/projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/internal/tree-table-unsort/tree-table-unsort.directive.ts similarity index 100% rename from projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/internal/tree-table-unsort.directive.ts rename to projects/cps-ui-kit/src/lib/components/cps-tree-table/directives/internal/tree-table-unsort/tree-table-unsort.directive.ts diff --git a/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.spec.ts b/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.spec.ts index 958ab3171..251f7a685 100644 --- a/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.spec.ts @@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { CombineLabelsPipe } from '../../../pipes/internal/combine-labels.pipe'; +import { CombineLabelsPipe } from '../../../pipes/internal/combine-labels/combine-labels.pipe'; import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../../services/cps-root-font-size/cps-root-font-size.service'; import { CpsTreeSelectComponent } from '../../cps-tree-select/cps-tree-select.component'; diff --git a/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.ts b/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.ts index c85463461..b064ef73c 100644 --- a/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.ts +++ b/projects/cps-ui-kit/src/lib/components/internal/cps-base-tree-dropdown/cps-base-tree-dropdown.component.ts @@ -22,12 +22,12 @@ import { TreeNode } from 'primeng/api'; import { generateUniqueId, logMissingAriaLabelError -} from '../../../utils/internal/accessibility-utils'; +} from '../../../utils/internal/accessibility-utils/accessibility-utils'; import { Subscription } from 'rxjs'; import { Tree } from 'primeng/tree'; import { isEqual } from 'lodash-es'; import { IconType, iconSizeType } from '../../cps-icon/cps-icon.component'; -import { convertSize } from '../../../utils/internal/size-utils'; +import { convertSize } from '../../../utils/internal/size-utils/size-utils'; import { CpsTooltipPosition } from '../../../directives/cps-tooltip/cps-tooltip.directive'; import { CpsMenuComponent } from '../../cps-menu/cps-menu.component'; import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../../services/cps-root-font-size/cps-root-font-size.service'; diff --git a/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.ts b/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.ts index 0e11bf321..195f8ab47 100644 --- a/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.ts +++ b/projects/cps-ui-kit/src/lib/directives/cps-tooltip/cps-tooltip.directive.ts @@ -8,7 +8,10 @@ import { SecurityContext, PLATFORM_ID } from '@angular/core'; -import { convertSize, parseSize } from '../../utils/internal/size-utils'; +import { + convertSize, + parseSize +} from '../../utils/internal/size-utils/size-utils'; import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { DomSanitizer } from '@angular/platform-browser'; import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../services/cps-root-font-size/cps-root-font-size.service'; diff --git a/projects/cps-ui-kit/src/lib/directives/internal/click-outside/click-outside.directive.spec.ts b/projects/cps-ui-kit/src/lib/directives/internal/click-outside/click-outside.directive.spec.ts new file mode 100644 index 000000000..b74fc5c6b --- /dev/null +++ b/projects/cps-ui-kit/src/lib/directives/internal/click-outside/click-outside.directive.spec.ts @@ -0,0 +1,136 @@ +import { Component, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ClickOutsideDirective } from './click-outside.directive'; + +@Component({ + template: ` +
+ Inside +
+ `, + imports: [ClickOutsideDirective] +}) +class TestHostComponent { + @ViewChild(ClickOutsideDirective) directive!: ClickOutsideDirective; + focused = false; + skipTargets: string[] = []; + clickOutsideCount = 0; + + onClickOutside() { + this.clickOutsideCount++; + } +} + +describe('ClickOutsideDirective', () => { + let fixture: ComponentFixture; + let component: TestHostComponent; + const appendedElements: HTMLElement[] = []; + + const appendOutsideElement = (classes: string[] = []): HTMLElement => { + const el = document.createElement('span'); + el.classList.add(...classes); + document.body.appendChild(el); + appendedElements.push(el); + return el; + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + appendedElements.forEach((el) => el.remove()); + appendedElements.length = 0; + }); + + it('should create', () => { + expect(component.directive).toBeTruthy(); + }); + + it('should not emit clickOutside when the host lacks the focused class', () => { + const outside = appendOutsideElement(); + + outside.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(component.clickOutsideCount).toBe(0); + }); + + describe('when the host has the focused class', () => { + beforeEach(() => { + component.focused = true; + fixture.detectChanges(); + }); + + it('should emit clickOutside when the click target is outside the host and connected to the DOM', () => { + const outside = appendOutsideElement(); + + outside.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(component.clickOutsideCount).toBe(1); + }); + + it('should not emit clickOutside when the click target is inside the host', () => { + const inside = fixture.debugElement.query(By.css('#inside')) + .nativeElement as HTMLElement; + + inside.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(component.clickOutsideCount).toBe(0); + }); + + it('should not emit clickOutside when the click target is the host itself', () => { + const host = fixture.debugElement.query(By.css('#host')) + .nativeElement as HTMLElement; + + host.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(component.clickOutsideCount).toBe(0); + }); + + it("should emit clickOutside when the click target's classList matches a configured skipTargets class", () => { + component.skipTargets = ['skip-me']; + fixture.detectChanges(); + + const inside = fixture.debugElement.query(By.css('#inside')) + .nativeElement as HTMLElement; + inside.classList.add('skip-me'); + + inside.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(component.clickOutsideCount).toBe(1); + }); + + it('should match any one of multiple configured skipTargets classes', () => { + component.skipTargets = ['skip-a', 'skip-b']; + fixture.detectChanges(); + + const inside = fixture.debugElement.query(By.css('#inside')) + .nativeElement as HTMLElement; + inside.classList.add('skip-b'); + + inside.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(component.clickOutsideCount).toBe(1); + }); + + it('should not emit clickOutside for a detached target not in skipTargets', () => { + const detached = document.createElement('span'); + + component.directive.onClick(detached); + + expect(component.clickOutsideCount).toBe(0); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/directives/internal/click-outside.directive.ts b/projects/cps-ui-kit/src/lib/directives/internal/click-outside/click-outside.directive.ts similarity index 100% rename from projects/cps-ui-kit/src/lib/directives/internal/click-outside.directive.ts rename to projects/cps-ui-kit/src/lib/directives/internal/click-outside/click-outside.directive.ts diff --git a/projects/cps-ui-kit/src/lib/pipes/internal/check-option-selected/check-option-selected.pipe.spec.ts b/projects/cps-ui-kit/src/lib/pipes/internal/check-option-selected/check-option-selected.pipe.spec.ts new file mode 100644 index 000000000..a0acb5cef --- /dev/null +++ b/projects/cps-ui-kit/src/lib/pipes/internal/check-option-selected/check-option-selected.pipe.spec.ts @@ -0,0 +1,97 @@ +import { CheckOptionSelectedPipe } from './check-option-selected.pipe'; + +describe('CheckOptionSelectedPipe', () => { + let pipe: CheckOptionSelectedPipe; + + beforeEach(() => { + pipe = new CheckOptionSelectedPipe(); + }); + + it('should create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + describe('multiple=false, returnObject=false', () => { + it('should return true when option[optionValue] deep-equals value', () => { + const option = { value: { id: 1 }, label: 'A' }; + expect(pipe.transform(option, { id: 1 }, false, false, 'value')).toBe( + true + ); + }); + + it('should return false when option[optionValue] does not deep-equal value', () => { + const option = { value: { id: 1 }, label: 'A' }; + expect(pipe.transform(option, { id: 2 }, false, false, 'value')).toBe( + false + ); + }); + }); + + describe('multiple=false, returnObject=true', () => { + it('should return true when the whole option deep-equals value', () => { + const option = { value: 1, label: 'A' }; + expect( + pipe.transform(option, { value: 1, label: 'A' }, false, true, 'value') + ).toBe(true); + }); + + it('should return false when the whole option does not deep-equal value', () => { + const option = { value: 1, label: 'A' }; + expect( + pipe.transform(option, { value: 2, label: 'B' }, false, true, 'value') + ).toBe(false); + }); + }); + + describe('multiple=true, returnObject=false', () => { + it('should return true when value contains an item deep-equal to option[optionValue]', () => { + const option = { value: { id: 1 }, label: 'A' }; + expect( + pipe.transform(option, [{ id: 2 }, { id: 1 }], true, false, 'value') + ).toBe(true); + }); + + it('should return false when value does not contain a matching item', () => { + const option = { value: { id: 1 }, label: 'A' }; + expect(pipe.transform(option, [{ id: 2 }], true, false, 'value')).toBe( + false + ); + }); + + it('should return false when value is an empty array', () => { + const option = { value: { id: 1 }, label: 'A' }; + expect(pipe.transform(option, [], true, false, 'value')).toBe(false); + }); + }); + + describe('multiple=true, returnObject=true', () => { + it('should return true when value contains an item deep-equal to option', () => { + const option = { value: 1, label: 'A' }; + expect( + pipe.transform( + option, + [ + { value: 2, label: 'B' }, + { value: 1, label: 'A' } + ], + true, + true, + 'value' + ) + ).toBe(true); + }); + + it('should return false when value does not contain a matching item', () => { + const option = { value: 1, label: 'A' }; + expect( + pipe.transform(option, [{ value: 2, label: 'B' }], true, true, 'value') + ).toBe(false); + }); + }); + + it('should return false rather than throw when value is undefined or null in multiple mode', () => { + const option = { value: { id: 1 }, label: 'A' }; + expect(pipe.transform(option, undefined, true, false, 'value')).toBe(false); + expect(pipe.transform(option, null, true, false, 'value')).toBe(false); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/pipes/internal/check-option-selected.pipe.ts b/projects/cps-ui-kit/src/lib/pipes/internal/check-option-selected/check-option-selected.pipe.ts similarity index 100% rename from projects/cps-ui-kit/src/lib/pipes/internal/check-option-selected.pipe.ts rename to projects/cps-ui-kit/src/lib/pipes/internal/check-option-selected/check-option-selected.pipe.ts diff --git a/projects/cps-ui-kit/src/lib/pipes/internal/combine-labels/combine-labels.pipe.spec.ts b/projects/cps-ui-kit/src/lib/pipes/internal/combine-labels/combine-labels.pipe.spec.ts new file mode 100644 index 000000000..d66473d10 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/pipes/internal/combine-labels/combine-labels.pipe.spec.ts @@ -0,0 +1,49 @@ +import { CombineLabelsPipe } from './combine-labels.pipe'; + +describe('CombineLabelsPipe', () => { + let pipe: CombineLabelsPipe; + + const options = [ + { value: 1, label: 'A' }, + { value: 2, label: 'B' }, + { value: 3, label: 'C' } + ]; + + beforeEach(() => { + pipe = new CombineLabelsPipe(); + }); + + it('should create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should map values to labels via options and join with ", " when returnObject is false', () => { + expect(pipe.transform([1, 3], options, 'value', 'label', false)).toBe( + 'A, C' + ); + }); + + it('should map unmatched values to an empty string segment when returnObject is false', () => { + expect(pipe.transform([1, 99], options, 'value', 'label', false)).toBe( + 'A, ' + ); + }); + + it('should read labelKey directly off each item when returnObject is true', () => { + const values = [ + { value: 1, label: 'A' }, + { value: 2, label: 'B' } + ]; + expect(pipe.transform(values, options, 'value', 'label', true)).toBe( + 'A, B' + ); + }); + + it('should return an empty string for an empty values array', () => { + expect(pipe.transform([], options, 'value', 'label', false)).toBe(''); + }); + + it('should return just the one label for a single value with no stray separator', () => { + expect(pipe.transform([2], options, 'value', 'label', false)).toBe('B'); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/pipes/internal/combine-labels.pipe.ts b/projects/cps-ui-kit/src/lib/pipes/internal/combine-labels/combine-labels.pipe.ts similarity index 100% rename from projects/cps-ui-kit/src/lib/pipes/internal/combine-labels.pipe.ts rename to projects/cps-ui-kit/src/lib/pipes/internal/combine-labels/combine-labels.pipe.ts diff --git a/projects/cps-ui-kit/src/lib/pipes/internal/label-by-value/label-by-value.pipe.spec.ts b/projects/cps-ui-kit/src/lib/pipes/internal/label-by-value/label-by-value.pipe.spec.ts new file mode 100644 index 000000000..657ba106e --- /dev/null +++ b/projects/cps-ui-kit/src/lib/pipes/internal/label-by-value/label-by-value.pipe.spec.ts @@ -0,0 +1,34 @@ +import { LabelByValuePipe } from './label-by-value.pipe'; + +describe('LabelByValuePipe', () => { + let pipe: LabelByValuePipe; + + const options = [ + { value: { id: 1 }, label: 'A' }, + { value: { id: 2 }, label: 'B' } + ]; + + beforeEach(() => { + pipe = new LabelByValuePipe(); + }); + + it('should create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it("should return the matching option's labelKey value", () => { + expect(pipe.transform({ id: 2 }, options, 'value', 'label')).toBe('B'); + }); + + it('should return an empty string when no option matches', () => { + expect(pipe.transform({ id: 99 }, options, 'value', 'label')).toBe(''); + }); + + it('should use deep equality rather than reference equality for the match', () => { + expect(pipe.transform({ id: 1 }, options, 'value', 'label')).toBe('A'); + }); + + it('should return an empty string when options is an empty array', () => { + expect(pipe.transform({ id: 1 }, [], 'value', 'label')).toBe(''); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/pipes/internal/label-by-value.pipe.ts b/projects/cps-ui-kit/src/lib/pipes/internal/label-by-value/label-by-value.pipe.ts similarity index 100% rename from projects/cps-ui-kit/src/lib/pipes/internal/label-by-value.pipe.ts rename to projects/cps-ui-kit/src/lib/pipes/internal/label-by-value/label-by-value.pipe.ts diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.spec.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.spec.ts index eb4ea17cc..544b8edfc 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.spec.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.spec.ts @@ -8,7 +8,7 @@ import { TestBed } from '@angular/core/testing'; import { Subject } from 'rxjs'; import { CpsDialogService } from './cps-dialog.service'; import { CpsDialogConfig } from './utils/cps-dialog-config'; -import { CpsDialogRef } from './utils/cps-dialog-ref'; +import { CpsDialogRef } from './utils/cps-dialog-ref/cps-dialog-ref'; import { CpsConfirmationComponent } from './internal/components/cps-confirmation/cps-confirmation.component'; @Component({ template: '' }) diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.ts index 1a141953a..4cc284f83 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/cps-dialog.service.ts @@ -14,7 +14,7 @@ import { OnDestroy } from '@angular/core'; import { DOCUMENT } from '@angular/common'; -import { CpsDialogRef } from './utils/cps-dialog-ref'; +import { CpsDialogRef } from './utils/cps-dialog-ref/cps-dialog-ref'; import { CpsDialogConfig } from './utils/cps-dialog-config'; import { CpsDialogComponent } from './internal/components/cps-dialog/cps-dialog.component'; import { CpsConfirmationComponent } from './internal/components/cps-confirmation/cps-confirmation.component'; diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-confirmation/cps-confirmation.component.spec.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-confirmation/cps-confirmation.component.spec.ts index 352bed11d..144b873c1 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-confirmation/cps-confirmation.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-confirmation/cps-confirmation.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CpsConfirmationComponent } from './cps-confirmation.component'; -import { CpsDialogRef } from '../../../utils/cps-dialog-ref'; +import { CpsDialogRef } from '../../../utils/cps-dialog-ref/cps-dialog-ref'; import { CpsDialogConfig } from '../../../utils/cps-dialog-config'; describe('CpsConfirmationComponent', () => { diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-confirmation/cps-confirmation.component.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-confirmation/cps-confirmation.component.ts index afea82add..639580e83 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-confirmation/cps-confirmation.component.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-confirmation/cps-confirmation.component.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { CpsButtonComponent } from '../../../../../components/cps-button/cps-button.component'; -import { CpsDialogRef } from '../../../utils/cps-dialog-ref'; +import { CpsDialogRef } from '../../../utils/cps-dialog-ref/cps-dialog-ref'; import { CpsDialogConfig } from '../../../utils/cps-dialog-config'; @Component({ @@ -12,7 +12,6 @@ import { CpsDialogConfig } from '../../../utils/cps-dialog-config'; export class CpsConfirmationComponent { subtitle = ''; - // eslint-disable-next-line no-useless-constructor constructor( private _dialogRef: CpsDialogRef, private _config: CpsDialogConfig diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.spec.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.spec.ts index 0e97e33b2..862f89d1d 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.spec.ts @@ -10,7 +10,7 @@ import { PrimeNG } from 'primeng/config'; import { DomHandler } from 'primeng/dom'; import { CpsDialogComponent } from './cps-dialog.component'; import { CpsDialogConfig } from '../../../utils/cps-dialog-config'; -import { CpsDialogRef } from '../../../utils/cps-dialog-ref'; +import { CpsDialogRef } from '../../../utils/cps-dialog-ref/cps-dialog-ref'; import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../../../cps-root-font-size/cps-root-font-size.service'; @Component({ template: '' }) diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts index 8b9b324e7..1f76cbfbc 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts @@ -35,10 +35,10 @@ import { PrimeNG } from 'primeng/config'; import { convertSize, parseSize -} from '../../../../../utils/internal/size-utils'; +} from '../../../../../utils/internal/size-utils/size-utils'; import { CpsDialogContentDirective } from '../../directives/cps-dialog-content.directive'; import { CpsDialogConfig } from '../../../utils/cps-dialog-config'; -import { CpsDialogRef } from '../../../utils/cps-dialog-ref'; +import { CpsDialogRef } from '../../../utils/cps-dialog-ref/cps-dialog-ref'; import { CpsButtonComponent } from '../../../../../components/cps-button/cps-button.component'; import { CpsInfoCircleComponent } from '../../../../../components/cps-info-circle/cps-info-circle.component'; import { CpsIconComponent } from '../../../../../components/cps-icon/cps-icon.component'; @@ -47,7 +47,7 @@ import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../../../cps-root-font-size/cps-r import { prefersReducedMotion, REDUCED_MOTION_DURATION -} from '../../../../../utils/internal/motion-utils'; +} from '../../../../../utils/internal/motion-utils/motion-utils'; const showAnimation = animation([ style({ transform: '{{transform}}', opacity: 0 }), diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-ref/cps-dialog-ref.spec.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-ref/cps-dialog-ref.spec.ts new file mode 100644 index 000000000..2228f7110 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-ref/cps-dialog-ref.spec.ts @@ -0,0 +1,157 @@ +import { Subject } from 'rxjs'; +import { CpsDialogRef } from './cps-dialog-ref'; +import { CpsDialogComponent } from '../../internal/components/cps-dialog/cps-dialog.component'; + +function makeMockContainer() { + return { + visible: true, + maximized: false as boolean | undefined, + toggleMaximized: jest.fn(), + _openStateChanged: new Subject(), + _dragStarted: new Subject(), + _dragEnded: new Subject(), + _resizeStarted: new Subject(), + _resizeEnded: new Subject(), + _maximizedStateChanged: new Subject() + }; +} + +describe('CpsDialogRef', () => { + let dialogRef: CpsDialogRef; + + beforeEach(() => { + dialogRef = new CpsDialogRef(); + }); + + it('should create', () => { + expect(dialogRef).toBeTruthy(); + }); + + it('should allow componentInstance and disableClose to be assigned', () => { + const instance = { foo: 'bar' }; + dialogRef.componentInstance = instance; + dialogRef.disableClose = true; + + expect(dialogRef.componentInstance).toBe(instance); + expect(dialogRef.disableClose).toBe(true); + }); + + describe('before a container instance is set', () => { + it('isOpen() should return false', () => { + expect(dialogRef.isOpen()).toBe(false); + }); + + it('isMaximized() should return false', () => { + expect(dialogRef.isMaximized()).toBe(false); + }); + + it('maximize()/minimize() should not throw', () => { + expect(() => dialogRef.maximize()).not.toThrow(); + expect(() => dialogRef.minimize()).not.toThrow(); + }); + }); + + describe('after a container instance is set', () => { + let container: ReturnType; + + beforeEach(() => { + container = makeMockContainer(); + dialogRef._setContainerInstance( + container as unknown as CpsDialogComponent + ); + }); + + it('isOpen() should reflect container.visible', () => { + expect(dialogRef.isOpen()).toBe(true); + container.visible = false; + expect(dialogRef.isOpen()).toBe(false); + }); + + it('isMaximized() should reflect container.maximized', () => { + expect(dialogRef.isMaximized()).toBe(false); + container.maximized = true; + expect(dialogRef.isMaximized()).toBe(true); + }); + + it('should emit onOpen exactly once even if the container emits _openStateChanged twice', () => { + const handler = jest.fn(); + dialogRef.onOpen.subscribe(handler); + + container._openStateChanged.next(); + container._openStateChanged.next(); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should forward drag/resize events from the matching container subjects', () => { + const dragStart = jest.fn(); + const dragEnd = jest.fn(); + const resizeStart = jest.fn(); + const resizeEnd = jest.fn(); + dialogRef.onDragStart.subscribe(dragStart); + dialogRef.onDragEnd.subscribe(dragEnd); + dialogRef.onResizeStart.subscribe(resizeStart); + dialogRef.onResizeEnd.subscribe(resizeEnd); + + const mouseEvent = new MouseEvent('mousedown'); + container._dragStarted.next(mouseEvent); + container._dragEnded.next(mouseEvent); + container._resizeStarted.next(mouseEvent); + container._resizeEnded.next(mouseEvent); + + expect(dragStart).toHaveBeenCalledWith(mouseEvent); + expect(dragEnd).toHaveBeenCalledWith(mouseEvent); + expect(resizeStart).toHaveBeenCalledWith(mouseEvent); + expect(resizeEnd).toHaveBeenCalledWith(mouseEvent); + }); + + it('should forward the maximized state via onMaximize', () => { + const handler = jest.fn(); + dialogRef.onMaximize.subscribe(handler); + + container._maximizedStateChanged.next(true); + container._maximizedStateChanged.next(false); + + expect(handler).toHaveBeenNthCalledWith(1, true); + expect(handler).toHaveBeenNthCalledWith(2, false); + }); + + it('close() should emit the result on onClose when disableClose is falsy', () => { + const handler = jest.fn(); + dialogRef.onClose.subscribe(handler); + + dialogRef.close('some-result'); + + expect(handler).toHaveBeenCalledWith('some-result'); + }); + + it('close() should not emit on onClose when disableClose is true', () => { + dialogRef.disableClose = true; + const handler = jest.fn(); + dialogRef.onClose.subscribe(handler); + + dialogRef.close('some-result'); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('destroy() should emit on onDestroy', () => { + const handler = jest.fn(); + dialogRef.onDestroy.subscribe(handler); + + dialogRef.destroy(); + + expect(handler).toHaveBeenCalled(); + }); + + it('maximize() should call container.toggleMaximized(true)', () => { + dialogRef.maximize(); + expect(container.toggleMaximized).toHaveBeenCalledWith(true); + }); + + it('minimize() should call container.toggleMaximized(false)', () => { + dialogRef.minimize(); + expect(container.toggleMaximized).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-ref.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-ref/cps-dialog-ref.ts similarity index 97% rename from projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-ref.ts rename to projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-ref/cps-dialog-ref.ts index 6569a3f60..ecb7ab57b 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-ref.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/utils/cps-dialog-ref/cps-dialog-ref.ts @@ -1,5 +1,5 @@ import { Observable, Subject, take } from 'rxjs'; -import { CpsDialogComponent } from '../internal/components/cps-dialog/cps-dialog.component'; +import { CpsDialogComponent } from '../../internal/components/cps-dialog/cps-dialog.component'; /** * Reference to an opened dialog, returned by CpsDialogService.open() and CpsDialogService.openConfirmationDialog(). diff --git a/projects/cps-ui-kit/src/lib/services/cps-notification/internal/components/cps-toast/cps-toast.component.ts b/projects/cps-ui-kit/src/lib/services/cps-notification/internal/components/cps-toast/cps-toast.component.ts index 02f84eb58..8729a1c55 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-notification/internal/components/cps-toast/cps-toast.component.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-notification/internal/components/cps-toast/cps-toast.component.ts @@ -11,7 +11,7 @@ import { import { CpsButtonComponent } from '../../../../../components/cps-button/cps-button.component'; import { CpsIconComponent } from '../../../../../components/cps-icon/cps-icon.component'; import { CommonModule } from '@angular/common'; -import { convertSize } from '../../../../../utils/internal/size-utils'; +import { convertSize } from '../../../../../utils/internal/size-utils/size-utils'; import { CpsNotificationAppearance, CpsNotificationConfig @@ -30,7 +30,7 @@ import { import { prefersReducedMotion, REDUCED_MOTION_DURATION -} from '../../../../../utils/internal/motion-utils'; +} from '../../../../../utils/internal/motion-utils/motion-utils'; @Component({ imports: [CommonModule, CpsButtonComponent, CpsIconComponent], diff --git a/projects/cps-ui-kit/src/lib/utils/colors-utils/colors-utils.spec.ts b/projects/cps-ui-kit/src/lib/utils/colors-utils/colors-utils.spec.ts new file mode 100644 index 000000000..41cb1ba88 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/utils/colors-utils/colors-utils.spec.ts @@ -0,0 +1,167 @@ +import { getCpsColors, getCSSColor, getTextColor } from './colors-utils'; + +type FakeStyleDeclaration = { + getPropertyValue: (name: string) => string; + [Symbol.iterator](): Iterator; +}; + +const makeStyleDeclaration = ( + props: Record +): FakeStyleDeclaration => { + const propNames = Object.keys(props); + return { + getPropertyValue: (name: string) => props[name] ?? '', + [Symbol.iterator]: function* () { + yield* propNames; + } + }; +}; + +const makeRule = ( + selectorText: string, + props: Record, + type = 1 +) => ({ + type, + selectorText, + style: makeStyleDeclaration(props) +}); + +const makeSheet = (cssRules: unknown[], href: string | null = null) => ({ + href, + cssRules +}); + +const makeFakeDocument = ( + styleSheets: unknown[], + origin = 'https://example.com' +): Document => + ({ + styleSheets, + defaultView: { location: { origin } } + }) as unknown as Document; + +describe('colors-utils', () => { + describe('getTextColor', () => { + it('should return black for a light background (hex)', () => { + expect(getTextColor('#FFFFFF')).toBe('#000000'); + expect(getTextColor('#F0F0F0')).toBe('#000000'); + }); + + it('should return white for a dark background (hex)', () => { + expect(getTextColor('#000000')).toBe('#FFFFFF'); + expect(getTextColor('#111111')).toBe('#FFFFFF'); + }); + + it('should accept rgb()/rgba() string form', () => { + expect(getTextColor('rgb(0, 0, 0)')).toBe('#FFFFFF'); + expect(getTextColor('rgba(255, 255, 255, 1)')).toBe('#000000'); + }); + + it('should accept 3-digit hex shorthand', () => { + expect(getTextColor('#fff')).toBe('#000000'); + expect(getTextColor('#000')).toBe('#FFFFFF'); + }); + + it('should resolve a mid-gray background to one deterministic value', () => { + expect(getTextColor('#767676')).toBe('#000000'); + }); + }); + + describe('getCSSColor', () => { + it('should return an empty string for a falsy value', () => { + expect(getCSSColor('', document)).toBe(''); + }); + + it('should return currentColor unchanged', () => { + expect(getCSSColor('currentColor', document)).toBe('currentColor'); + }); + + it('should return a valid CSS color keyword unchanged', () => { + expect(getCSSColor('red', document)).toBe('red'); + }); + + it('should wrap an unknown value as a CSS variable reference', () => { + expect(getCSSColor('not-a-color', document)).toBe( + 'var(--cps-color-not-a-color)' + ); + }); + + it('should wrap a bare palette key as a CSS variable reference', () => { + expect(getCSSColor('primary', document)).toBe('var(--cps-color-primary)'); + }); + }); + + describe('getCpsColors', () => { + it('should return an empty array when there are no stylesheets', () => { + expect(getCpsColors(makeFakeDocument([]))).toEqual([]); + }); + + it('should return an empty array when no stylesheet has a :root rule', () => { + const sheet = makeSheet([ + makeRule('.foo', { '--cps-color-primary': '#123456' }) + ]); + expect(getCpsColors(makeFakeDocument([sheet]))).toEqual([]); + }); + + it('should collect only --cps-color-* custom properties from :root rules', () => { + const sheet = makeSheet([ + makeRule(':root', { + '--cps-color-primary': '#123456', + '--cps-color-secondary': '#abcdef', + '--other-var': '1px' + }) + ]); + + expect(getCpsColors(makeFakeDocument([sheet]))).toEqual([ + ['--cps-color-primary', '#123456'], + ['--cps-color-secondary', '#abcdef'] + ]); + }); + + it('should ignore rules for non-:root selectors', () => { + const sheet = makeSheet([ + makeRule(':root', { '--cps-color-primary': '#123456' }), + makeRule("[data-theme='dark']", { '--cps-color-primary': '#000000' }) + ]); + + expect(getCpsColors(makeFakeDocument([sheet]))).toEqual([ + ['--cps-color-primary', '#123456'] + ]); + }); + + it('should ignore non-style rules', () => { + const sheet = makeSheet([ + makeRule(':root', { '--cps-color-primary': '#123456' }, 3) + ]); + + expect(getCpsColors(makeFakeDocument([sheet]))).toEqual([]); + }); + + it('should include same-domain stylesheets with no href (inline styles)', () => { + const sheet = makeSheet( + [makeRule(':root', { '--cps-color-primary': '#123456' })], + null + ); + + expect(getCpsColors(makeFakeDocument([sheet]))).toEqual([ + ['--cps-color-primary', '#123456'] + ]); + }); + + it('should skip stylesheets from a different origin', () => { + const sameOriginSheet = makeSheet( + [makeRule(':root', { '--cps-color-primary': '#123456' })], + 'https://example.com/styles.css' + ); + const crossOriginSheet = makeSheet( + [makeRule(':root', { '--cps-color-secondary': '#abcdef' })], + 'https://cross-origin.example/x.css' + ); + + expect( + getCpsColors(makeFakeDocument([sameOriginSheet, crossOriginSheet])) + ).toEqual([['--cps-color-primary', '#123456']]); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/utils/colors-utils.ts b/projects/cps-ui-kit/src/lib/utils/colors-utils/colors-utils.ts similarity index 100% rename from projects/cps-ui-kit/src/lib/utils/colors-utils.ts rename to projects/cps-ui-kit/src/lib/utils/colors-utils/colors-utils.ts diff --git a/projects/cps-ui-kit/src/lib/utils/internal/accessibility-utils/accessibility-utils.spec.ts b/projects/cps-ui-kit/src/lib/utils/internal/accessibility-utils/accessibility-utils.spec.ts new file mode 100644 index 000000000..3438895f2 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/utils/internal/accessibility-utils/accessibility-utils.spec.ts @@ -0,0 +1,88 @@ +import { + focusElement, + generateUniqueId, + logMissingAriaLabelError +} from './accessibility-utils'; + +describe('accessibility-utils', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('generateUniqueId', () => { + it('should return a string prefixed with the default prefix when none is given', () => { + expect(generateUniqueId()).toMatch(/^cps-[a-z0-9]+-\d+$/); + }); + + it('should return a string prefixed with the given prefix', () => { + expect(generateUniqueId('custom')).toMatch(/^custom-[a-z0-9]+-\d+$/); + }); + + it('should return a different value on each call', () => { + const first = generateUniqueId(); + const second = generateUniqueId(); + expect(first).not.toBe(second); + }); + }); + + describe('focusElement', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should not call focus synchronously', () => { + const element = { focus: jest.fn() } as unknown as HTMLElement; + focusElement(element); + expect(element.focus).not.toHaveBeenCalled(); + }); + + it('should call focus after the timer fires', () => { + const element = { focus: jest.fn() } as unknown as HTMLElement; + focusElement(element); + jest.runAllTimers(); + expect(element.focus).toHaveBeenCalledTimes(1); + }); + + it('should not throw and not schedule a timer when element is undefined', () => { + const spy = jest.spyOn(window, 'setTimeout'); + expect(() => focusElement(undefined)).not.toThrow(); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('logMissingAriaLabelError', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should log an error when both label and ariaLabel are omitted', () => { + logMissingAriaLabelError('CpsTestComponent'); + expect(console.error).toHaveBeenCalledWith( + 'CpsTestComponent: unlabeled component must have an ariaLabel for accessibility.' + ); + }); + + it('should log an error when both label and ariaLabel are whitespace-only', () => { + logMissingAriaLabelError('CpsTestComponent', ' ', ' '); + expect(console.error).toHaveBeenCalledTimes(1); + }); + + it('should not log when label is a non-empty string', () => { + logMissingAriaLabelError('CpsTestComponent', 'Some label'); + expect(console.error).not.toHaveBeenCalled(); + }); + + it('should not log when ariaLabel is a non-empty string', () => { + logMissingAriaLabelError( + 'CpsTestComponent', + undefined, + 'Some aria label' + ); + expect(console.error).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/utils/internal/accessibility-utils.ts b/projects/cps-ui-kit/src/lib/utils/internal/accessibility-utils/accessibility-utils.ts similarity index 100% rename from projects/cps-ui-kit/src/lib/utils/internal/accessibility-utils.ts rename to projects/cps-ui-kit/src/lib/utils/internal/accessibility-utils/accessibility-utils.ts diff --git a/projects/cps-ui-kit/src/lib/utils/internal/motion-utils.spec.ts b/projects/cps-ui-kit/src/lib/utils/internal/motion-utils/motion-utils.spec.ts similarity index 100% rename from projects/cps-ui-kit/src/lib/utils/internal/motion-utils.spec.ts rename to projects/cps-ui-kit/src/lib/utils/internal/motion-utils/motion-utils.spec.ts diff --git a/projects/cps-ui-kit/src/lib/utils/internal/motion-utils.ts b/projects/cps-ui-kit/src/lib/utils/internal/motion-utils/motion-utils.ts similarity index 100% rename from projects/cps-ui-kit/src/lib/utils/internal/motion-utils.ts rename to projects/cps-ui-kit/src/lib/utils/internal/motion-utils/motion-utils.ts diff --git a/projects/cps-ui-kit/src/lib/utils/internal/size-utils/size-utils.spec.ts b/projects/cps-ui-kit/src/lib/utils/internal/size-utils/size-utils.spec.ts new file mode 100644 index 000000000..eb05cdc57 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/utils/internal/size-utils/size-utils.spec.ts @@ -0,0 +1,81 @@ +import { convertSize, parseSize } from './size-utils'; + +describe('size-utils', () => { + describe('convertSize', () => { + it('should return empty string for null or undefined', () => { + expect(convertSize(null)).toBe(''); + expect(convertSize(undefined)).toBe(''); + }); + + it('should return empty string for empty or whitespace-only input', () => { + expect(convertSize('')).toBe(''); + expect(convertSize(' ')).toBe(''); + }); + + it('should append px to a bare numeric input', () => { + expect(convertSize(10)).toBe('10px'); + expect(convertSize(0)).toBe('0px'); + expect(convertSize(-5)).toBe('-5px'); + }); + + it('should append px to a numeric-looking string, trimming whitespace', () => { + expect(convertSize('10')).toBe('10px'); + expect(convertSize('10.5')).toBe('10.5px'); + expect(convertSize(' 10 ')).toBe('10px'); + }); + + it('should return supported unit values unchanged', () => { + expect(convertSize('10px')).toBe('10px'); + expect(convertSize('1.5rem')).toBe('1.5rem'); + expect(convertSize('2em')).toBe('2em'); + expect(convertSize('50%')).toBe('50%'); + expect(convertSize('10vw')).toBe('10vw'); + }); + + it('should return keyword values unchanged, case-insensitively', () => { + expect(convertSize('auto')).toBe('auto'); + expect(convertSize('AUTO')).toBe('AUTO'); + expect(convertSize('inherit')).toBe('inherit'); + expect(convertSize('none')).toBe('none'); + }); + + it('should return function-call values unchanged', () => { + expect(convertSize('calc(100% - 10px)')).toBe('calc(100% - 10px)'); + expect(convertSize('var(--x)')).toBe('var(--x)'); + expect(convertSize('min(10px, 5%)')).toBe('min(10px, 5%)'); + }); + + it('should return empty string for invalid or unrecognized values', () => { + expect(convertSize('banana')).toBe(''); + expect(convertSize('10xyz')).toBe(''); + }); + }); + + describe('parseSize', () => { + it('should parse px values', () => { + expect(parseSize('10px')).toEqual({ value: 10, unit: 'px' }); + }); + + it('should parse rem values', () => { + expect(parseSize('1.5rem')).toEqual({ value: 1.5, unit: 'rem' }); + }); + + it('should parse negative em values', () => { + expect(parseSize('-2em')).toEqual({ value: -2, unit: 'em' }); + }); + + it('should parse percentage values', () => { + expect(parseSize('50%')).toEqual({ value: 50, unit: '%' }); + }); + + it('should return null for units it does not support', () => { + expect(parseSize('10vw')).toBeNull(); + }); + + it('should return null for non-matching strings', () => { + expect(parseSize('auto')).toBeNull(); + expect(parseSize('')).toBeNull(); + expect(parseSize('10')).toBeNull(); + }); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts b/projects/cps-ui-kit/src/lib/utils/internal/size-utils/size-utils.ts similarity index 100% rename from projects/cps-ui-kit/src/lib/utils/internal/size-utils.ts rename to projects/cps-ui-kit/src/lib/utils/internal/size-utils/size-utils.ts diff --git a/projects/cps-ui-kit/src/public-api.ts b/projects/cps-ui-kit/src/public-api.ts index 9b50661b1..e5e6bbda8 100644 --- a/projects/cps-ui-kit/src/public-api.ts +++ b/projects/cps-ui-kit/src/public-api.ts @@ -54,7 +54,7 @@ export * from './lib/directives/cps-tooltip/cps-tooltip.directive'; export * from './lib/services/cps-dialog/cps-dialog.service'; export * from './lib/services/cps-dialog/utils/cps-dialog-config'; -export * from './lib/services/cps-dialog/utils/cps-dialog-ref'; +export * from './lib/services/cps-dialog/utils/cps-dialog-ref/cps-dialog-ref'; export * from './lib/services/cps-notification/cps-notification.service'; export * from './lib/services/cps-notification/utils/cps-notification-config'; @@ -65,4 +65,4 @@ export * from './lib/services/cps-theme/cps-theme.service'; export * from './lib/services/cps-cron-validation/cps-cron-validation.service'; export * from './lib/services/cps-live-announcer/cps-live-announcer.service'; -export * from './lib/utils/colors-utils'; +export * from './lib/utils/colors-utils/colors-utils';