diff --git a/apps/styleguide/package.json b/apps/styleguide/package.json index b74162e..33d5ab4 100644 --- a/apps/styleguide/package.json +++ b/apps/styleguide/package.json @@ -1,6 +1,6 @@ { "name": "@shiftcode/styleguide", - "version": "15.1.1", + "version": "15.2.0-pr83.1", "private": true, "type": "module", "scripts": { diff --git a/lerna.json b/lerna.json index efe1c41..ab3ae7b 100644 --- a/lerna.json +++ b/lerna.json @@ -2,7 +2,7 @@ "$schema": "node_modules/lerna/schemas/lerna-schema.json", "useNx": false, "packages": ["libs/*", "apps/*"], - "version": "15.1.1", + "version": "15.2.0-pr83.1", "command": { "version": { "allowBranch": "*", diff --git a/libs/components/package.json b/libs/components/package.json index 93525f9..5b80847 100644 --- a/libs/components/package.json +++ b/libs/components/package.json @@ -1,6 +1,6 @@ { "name": "@shiftcode/ngx-components", - "version": "15.1.1", + "version": "15.2.0-pr83.1", "repository": "https://github.com/shiftcode/sc-ng-commons-public", "license": "MIT", "author": "shiftcode GmbH ", diff --git a/libs/components/src/lib/apply/apply.pipe.spec.ts b/libs/components/src/lib/apply/apply.pipe.spec.ts new file mode 100644 index 0000000..7b7b3c8 --- /dev/null +++ b/libs/components/src/lib/apply/apply.pipe.spec.ts @@ -0,0 +1,65 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { describe, expect, it, vi } from 'vitest' + +import { ApplyPipe } from './apply.pipe' + +describe('ApplyPipe', () => { + it('calls the provided function with the provided value', () => { + const pipe = new ApplyPipe() + + const input = 'hello' + const toUpperCaseFn = vi.fn((arg: string) => arg.toUpperCase()) + + pipe.transform(input, toUpperCaseFn) + expect(toUpperCaseFn).toHaveBeenCalledWith(input) + }) + + it('should return the result of the function call', () => { + const pipe = new ApplyPipe() + + const input = 42 + const doubleFn = (arg: number) => arg * 2 + const expected = 84 + + const result = pipe.transform(input, doubleFn) + expect(result).toEqual(expected) + }) + + it('works integrated', () => { + const squareFn = vi.fn((value: number) => value * value) + const doubleFn = vi.fn((value: number) => 2 * value) + + @Component({ + selector: 'sc-test-component', + template: '{{prefix()}}{{ value() | apply: fn() }}', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ApplyPipe], + }) + class TestComponent { + readonly prefix = signal('Value: ') + readonly value = signal(4) + readonly fn = signal(doubleFn) + } + + const fixture = TestBed.createComponent(TestComponent) + TestBed.tick() + const component = fixture.componentInstance + const el = fixture.nativeElement as HTMLElement + + expect(el.innerHTML).toBe('Value: 8') + + // new fn and new value + component.value.set(5) + component.fn.set(squareFn) + TestBed.tick() + expect(el.innerHTML).toBe('Value: 25') + + // rerender with the same value + component.prefix.set('Hello: ') + TestBed.tick() + expect(el.innerHTML).toBe('Hello: 25') + // should have been called once only since pure + expect(squareFn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/libs/components/src/lib/apply/apply.pipe.ts b/libs/components/src/lib/apply/apply.pipe.ts new file mode 100644 index 0000000..7f5f2fd --- /dev/null +++ b/libs/components/src/lib/apply/apply.pipe.ts @@ -0,0 +1,15 @@ +import { Pipe, PipeTransform } from '@angular/core' + +/** + * generic pipe to call one-param functions from template and make use of angular pure pipe optimization. + * @example + * ```angular-html + * {{ myDate | apply: myDateFormatter }} + * ``` + */ +@Pipe({ name: 'apply' }) +export class ApplyPipe implements PipeTransform { + transform(value: T, fn: (arg: T) => R): R { + return fn(value) + } +} diff --git a/libs/components/src/public-api.ts b/libs/components/src/public-api.ts index 1dab23e..27d7064 100644 --- a/libs/components/src/public-api.ts +++ b/libs/components/src/public-api.ts @@ -1,3 +1,6 @@ +// apply +export * from './lib/apply/apply.pipe' + // svg export * from './lib/svg/svg.component' export * from './lib/svg/svg-base.directive' diff --git a/libs/core/package.json b/libs/core/package.json index 8d01aaf..b963da0 100644 --- a/libs/core/package.json +++ b/libs/core/package.json @@ -1,6 +1,6 @@ { "name": "@shiftcode/ngx-core", - "version": "15.1.1", + "version": "15.2.0-pr83.1", "repository": "https://github.com/shiftcode/sc-ng-commons-public", "license": "MIT", "author": "shiftcode GmbH ", diff --git a/libs/core/src/lib/logger/with-custom-log-transport.function.spec.ts b/libs/core/src/lib/logger/with-custom-log-transport.function.spec.ts index aaf5067..32dcfe5 100644 --- a/libs/core/src/lib/logger/with-custom-log-transport.function.spec.ts +++ b/libs/core/src/lib/logger/with-custom-log-transport.function.spec.ts @@ -52,4 +52,21 @@ describe('withCustomLogTransport', () => { ['test message'], ) }) + + test('useExisting reuses the provided singleton instance', () => { + TestBed.configureTestingModule({ + providers: [CustomLogTransport, provideLogger(withCustomLogTransport(CustomLogTransport, true))], + }) + + // Ensure services get instantiated + void TestBed.inject(LoggerService) + + const customTransportSingleton = TestBed.inject(CustomLogTransport) + + // log transport is provided with `multi` - thus it is an array. + const customTransportFromLogger = TestBed.inject(LogTransport) as unknown as LogTransport[] + + expect(customTransportFromLogger[0]).toBeInstanceOf(CustomLogTransport) + expect(customTransportFromLogger[0]).toBe(customTransportSingleton) + }) }) diff --git a/libs/core/src/lib/logger/with-custom-log-transport.function.ts b/libs/core/src/lib/logger/with-custom-log-transport.function.ts index b21f017..8641a4b 100644 --- a/libs/core/src/lib/logger/with-custom-log-transport.function.ts +++ b/libs/core/src/lib/logger/with-custom-log-transport.function.ts @@ -6,11 +6,19 @@ import { LoggerFeatureKind } from './logger-feature-kind.enum' /** * LoggerFeature to use with {@link provideLogger} that registers a custom LogTransport implementation. - * @param transportClass - The LogTransport implementation class to use + * @param transportClass - The LogTransport implementation class to use. + * @param useExisting - If `true`, the `transportClass` will be registered with `useExisting` instead of `useClass`. + * This requires `transportClass` to already be registered as a provider; otherwise Angular will throw a provider-not-found error at runtime. */ -export function withCustomLogTransport(transportClass: Type): LoggerFeature { +export function withCustomLogTransport(transportClass: Type, useExisting = false): LoggerFeature { return { kind: LoggerFeatureKind.TRANSPORT, - providers: [{ provide: LogTransport, useClass: transportClass, multi: true }], + providers: [ + { + provide: LogTransport, + multi: true, + ...(useExisting ? { useExisting: transportClass } : { useClass: transportClass }), + }, + ], } } diff --git a/libs/core/src/lib/static-utils/inputs-of.type.spec.ts b/libs/core/src/lib/static-utils/inputs-of.type.spec.ts new file mode 100644 index 0000000..8c8657c --- /dev/null +++ b/libs/core/src/lib/static-utils/inputs-of.type.spec.ts @@ -0,0 +1,156 @@ +import { Directive, input, model, signal } from '@angular/core' +import { describe, expectTypeOf, it } from 'vitest' + +import { InputsOf } from './inputs-of.type' + +describe('InputsOf', () => { + it('extracts InputSignal properties with their unwrapped types', () => { + @Directive() + class TestClazz { + readonly name = input.required() + readonly age = input(0) + readonly isActive = signal(true) + } + + type Result = InputsOf + + expectTypeOf().toEqualTypeOf<{ + readonly name: string + readonly age: number + }>() + }) + + it('extracts ModelSignal properties with their unwrapped types', () => { + @Directive() + class TestClazz { + readonly count = model(0) + readonly items = model([]) + readonly isVisible = signal(false) + } + + type Result = InputsOf + + expectTypeOf().toEqualTypeOf<{ + readonly count: number + readonly items: string[] + }>() + }) + + it('excludes regular signals and non-signal properties', () => { + @Directive() + class TestClazz { + readonly name = input('default') + readonly regularSignal = signal(true) + readonly plainProperty = 'test' + readonly method = () => {} + } + + type Result = InputsOf + + expectTypeOf().toEqualTypeOf<{ + readonly name: string + }>() + }) + + it('handles mixed InputSignal and ModelSignal', () => { + @Directive() + class TestClazz { + readonly title = input.required() + readonly description = input('default') + readonly status = model<'active' | 'inactive'>('active') + readonly count = model(0) + readonly timestamp = signal(Date.now()) + readonly regular = 'prop' + } + + type Result = InputsOf + + expectTypeOf().toEqualTypeOf<{ + readonly title: string + readonly description: string + readonly status: 'active' | 'inactive' + readonly count: number + }>() + }) + + it('handles complex types in signals', () => { + interface User { + id: string + name: string + email: string + } + + @Directive() + class TestClazz { + readonly user = input.required() + readonly roles = input([]) + readonly metadata = model>({}) + } + + type Result = InputsOf + + expectTypeOf().toEqualTypeOf<{ + readonly user: User + readonly roles: string[] + readonly metadata: Record + }>() + }) + + it('handles empty component (no inputs or models)', () => { + @Directive() + class EmptyComponent { + readonly counter = signal(0) + readonly value = 42 + } + + type Result = InputsOf + + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + expectTypeOf().toEqualTypeOf<{}>() + }) + + it('handles generic types', () => { + @Directive() + class TestClazz { + readonly items = input.required() + readonly selectedItem = model(null) + } + + type StringArrayResult = InputsOf> + + expectTypeOf().toEqualTypeOf<{ + readonly items: string[] + readonly selectedItem: string | null + }>() + }) + + it('handles optional input signals without default value as potentially undefined', () => { + @Directive() + class TestClazz { + readonly required = input.required() + readonly optional = input() + } + + type Result = InputsOf + + expectTypeOf().toEqualTypeOf<{ + readonly required: string + readonly optional: string | undefined + }>() + }) + + it('handles nullable types in signals', () => { + @Directive() + class TestClazz { + readonly user = input<{ name: string } | null>(null) + readonly items = model(null) + } + + type Result = InputsOf + + expectTypeOf().toEqualTypeOf<{ + readonly user: { name: string } | null + readonly items: string[] | null + }>() + }) +}) diff --git a/libs/core/src/lib/static-utils/inputs-of.type.ts b/libs/core/src/lib/static-utils/inputs-of.type.ts new file mode 100644 index 0000000..c5a7bf5 --- /dev/null +++ b/libs/core/src/lib/static-utils/inputs-of.type.ts @@ -0,0 +1,26 @@ +import { InputSignal, ModelSignal } from '@angular/core' + +/** + * Extracts all properties from a type that are InputSignal or ModelSignal, + * Caveat: There's no way to differ between required inputs and inputs with default values, as both only expose the non-undefined type. + * @returns a record where the value is the unwrapped signal type. + * @example + * ```ts + * class MyComponent { + * readonly name = input.required() + * readonly age = model(30) + * readonly isActive = signal(true) + * } + * type MyComponentInputs = InputsOf + * // MyComponentInputs is equivalent to: { readonly name: string; readonly age: number; } + * ``` + */ +export type InputsOf = { + readonly [K in keyof T as T[K] extends InputSignal | ModelSignal ? K : never]: T[K] extends InputSignal< + infer U + > + ? U + : T[K] extends ModelSignal + ? U + : never +} diff --git a/libs/core/src/lib/static-utils/on-init.function.spec.ts b/libs/core/src/lib/static-utils/on-init.function.spec.ts new file mode 100644 index 0000000..ef7da5d --- /dev/null +++ b/libs/core/src/lib/static-utils/on-init.function.spec.ts @@ -0,0 +1,89 @@ +import { Component, signal } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { describe, expect, it, vi } from 'vitest' + +import { onInit } from './on-init.function' + +describe('onInit', () => { + it('throws when used outside an injection context', () => { + const initFn = vi.fn() + + expect(() => onInit(initFn)).toThrow() + }) + + it('does not call initFn before effects are flushed', () => { + const initFn = vi.fn() + + TestBed.runInInjectionContext(() => onInit(initFn)) + + expect(initFn).not.toHaveBeenCalled() + }) + + it('calls initFn exactly once when effects are flushed', () => { + const initFn = vi.fn() + + TestBed.runInInjectionContext(() => onInit(initFn)) + TestBed.tick() + + expect(initFn).toHaveBeenCalledTimes(1) + }) + + it('does not call initFn again on subsequent flushes even when reading changed signals (runs only once)', () => { + const foo = signal('foo') + const initFn = vi.fn(() => { + foo() + }) + + TestBed.runInInjectionContext(() => onInit(initFn)) + TestBed.tick() + foo.set('bar') + TestBed.tick() + + expect(initFn).toHaveBeenCalledTimes(1) + }) + + it('registers and calls the cleanup function when the injector is destroyed', () => { + const cleanup = vi.fn() + const initFn = vi.fn().mockReturnValue(cleanup) + + TestBed.runInInjectionContext(() => onInit(initFn)) + TestBed.tick() + + expect(cleanup).not.toHaveBeenCalled() + + TestBed.resetTestingModule() + + expect(cleanup).toHaveBeenCalledTimes(1) + }) + + it('does not register a cleanup when initFn returns a non-function value', () => { + // returning a truthy non-function should not cause errors + const initFn = vi.fn().mockReturnValue(42 as unknown as void) + + TestBed.runInInjectionContext(() => onInit(initFn)) + + expect(() => TestBed.tick()).not.toThrow() + expect(() => TestBed.resetTestingModule()).not.toThrow() + }) + + it('works integrated on a Component', () => { + const cleanUpFn = vi.fn(() => void 0) + + const initFn = vi.fn().mockReturnValue(cleanUpFn) + + @Component({ template: '' }) + class TestComponent { + constructor() { + onInit(initFn) + } + } + + const fix = TestBed.createComponent(TestComponent) + TestBed.tick() + expect(initFn).toHaveBeenCalledTimes(1) + expect(cleanUpFn).not.toHaveBeenCalled() + + fix.destroy() + expect(cleanUpFn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/libs/core/src/lib/static-utils/on-init.function.ts b/libs/core/src/lib/static-utils/on-init.function.ts new file mode 100644 index 0000000..37d2d7c --- /dev/null +++ b/libs/core/src/lib/static-utils/on-init.function.ts @@ -0,0 +1,25 @@ +import { effect, EffectCleanupFn, untracked } from '@angular/core' + +/** + * Utility function to replace ngOnInit method. + * Runs as an effect exactly once and uses the returned function as the cleanup, which will be executed onDestroy + * Needs to be run in an injection context (e.g. in your constructor). + * + * @example + * ```ts + * onInit(() => { + * const subscription = someObservable.subscribe(...) + * return () => subscription.unsubscribe() + * }) + * ``` + */ +export function onInit(initFn: () => void | EffectCleanupFn) { + // we use `untracked` to ensure the effect is called only once as no dependency will re-trigger it. + // the cleanup is then effectively called in the ngOnDestroy lifecycle. + effect((onCleanup) => { + const cleanup = untracked(initFn) + if (typeof cleanup === 'function') { + onCleanup(cleanup) + } + }) +} diff --git a/libs/core/src/public-api.ts b/libs/core/src/public-api.ts index f1ad8bc..fb5156e 100644 --- a/libs/core/src/public-api.ts +++ b/libs/core/src/public-api.ts @@ -62,9 +62,11 @@ export * from './lib/script-loader/script-loader-error.model' export * from './lib/scroll-to/scroll-to.service' // static utils +export * from './lib/static-utils/inputs-of.type' export * from './lib/static-utils/is-input-element.function' export * from './lib/static-utils/jwt-helper' export * from './lib/static-utils/key-names.const' +export * from './lib/static-utils/on-init.function' export * from './lib/static-utils/regex' export * from './lib/static-utils/rxjs/filter-if-falsy.operator' export * from './lib/static-utils/rxjs/filter-if-instance-of.operator'