Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/styleguide/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@shiftcode/styleguide",
"version": "15.1.1",
"version": "15.2.0-pr83.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion lerna.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*",
Expand Down
2 changes: 1 addition & 1 deletion libs/components/package.json
Original file line number Diff line number Diff line change
@@ -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 <team@shiftcode.ch>",
Expand Down
65 changes: 65 additions & 0 deletions libs/components/src/lib/apply/apply.pipe.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Comment thread
mumenthalers marked this conversation as resolved.
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)
})
})
15 changes: 15 additions & 0 deletions libs/components/src/lib/apply/apply.pipe.ts
Original file line number Diff line number Diff line change
@@ -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' })
Comment thread
mumenthalers marked this conversation as resolved.
export class ApplyPipe implements PipeTransform {
transform<T, R>(value: T, fn: (arg: T) => R): R {
return fn(value)
}
}
3 changes: 3 additions & 0 deletions libs/components/src/public-api.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
2 changes: 1 addition & 1 deletion libs/core/package.json
Original file line number Diff line number Diff line change
@@ -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 <team@shiftcode.ch>",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
14 changes: 11 additions & 3 deletions libs/core/src/lib/logger/with-custom-log-transport.function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LogTransport>): LoggerFeature {
export function withCustomLogTransport(transportClass: Type<LogTransport>, useExisting = false): LoggerFeature {
return {
kind: LoggerFeatureKind.TRANSPORT,
providers: [{ provide: LogTransport, useClass: transportClass, multi: true }],
providers: [
{
provide: LogTransport,
multi: true,
...(useExisting ? { useExisting: transportClass } : { useClass: transportClass }),
},
Comment thread
mumenthalers marked this conversation as resolved.
],
}
}
156 changes: 156 additions & 0 deletions libs/core/src/lib/static-utils/inputs-of.type.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
readonly age = input<number>(0)
readonly isActive = signal(true)
}

type Result = InputsOf<TestClazz>

expectTypeOf<Result>().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<string[]>([])
readonly isVisible = signal(false)
}

type Result = InputsOf<TestClazz>

expectTypeOf<Result>().toEqualTypeOf<{
readonly count: number
readonly items: string[]
}>()
})

it('excludes regular signals and non-signal properties', () => {
@Directive()
class TestClazz {
readonly name = input<string>('default')
readonly regularSignal = signal(true)
readonly plainProperty = 'test'
readonly method = () => {}
}

type Result = InputsOf<TestClazz>

expectTypeOf<Result>().toEqualTypeOf<{
readonly name: string
}>()
})

it('handles mixed InputSignal and ModelSignal', () => {
@Directive()
class TestClazz {
readonly title = input.required<string>()
readonly description = input<string>('default')
readonly status = model<'active' | 'inactive'>('active')
readonly count = model(0)
readonly timestamp = signal(Date.now())
readonly regular = 'prop'
}

type Result = InputsOf<TestClazz>

expectTypeOf<Result>().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<User>()
readonly roles = input<string[]>([])
readonly metadata = model<Record<string, unknown>>({})
}

type Result = InputsOf<TestClazz>

expectTypeOf<Result>().toEqualTypeOf<{
readonly user: User
readonly roles: string[]
readonly metadata: Record<string, unknown>
}>()
})

it('handles empty component (no inputs or models)', () => {
@Directive()
class EmptyComponent {
readonly counter = signal(0)
readonly value = 42
}

type Result = InputsOf<EmptyComponent>

// eslint-disable-next-line @typescript-eslint/no-empty-object-type
expectTypeOf<Result>().toEqualTypeOf<{}>()
})

it('handles generic types', () => {
@Directive()
class TestClazz<T> {
readonly items = input.required<T[]>()
readonly selectedItem = model<T | null>(null)
}

type StringArrayResult = InputsOf<TestClazz<string>>

expectTypeOf<StringArrayResult>().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<string>()
readonly optional = input<string>()
}

type Result = InputsOf<TestClazz>

expectTypeOf<Result>().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<string[] | null>(null)
}

type Result = InputsOf<TestClazz>

expectTypeOf<Result>().toEqualTypeOf<{
readonly user: { name: string } | null
readonly items: string[] | null
}>()
})
})
26 changes: 26 additions & 0 deletions libs/core/src/lib/static-utils/inputs-of.type.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
* readonly age = model(30)
* readonly isActive = signal(true)
* }
* type MyComponentInputs = InputsOf<MyComponent>
* // MyComponentInputs is equivalent to: { readonly name: string; readonly age: number; }
* ```
*/
export type InputsOf<T> = {
readonly [K in keyof T as T[K] extends InputSignal<any> | ModelSignal<any> ? K : never]: T[K] extends InputSignal<
infer U
>
? U
: T[K] extends ModelSignal<infer U>
? U
: never
}
Loading