Skip to content

Commit

Permalink
feat(angular-query): support required input signal on injectMutation (#…
Browse files Browse the repository at this point in the history
…7016)

* test(angular-query): add set signal input test utils

* test(angular-query): add some missing test for required input signal to inject query

* feat(angular-query): add required input signal support to inject mutation

* feat(angular-query): add inject mutation test

* feat(angular-query): add lazy signal initializer

* feat(angular-query): add required input signal support for injectMutationState

* feat(angular-query): lazy signal initializer test

* feat(angular-query): lazyInit test and init without reactive context

---------

Co-authored-by: Arnoud <6420061+arnoud-dv@users.noreply.github.com>
  • Loading branch information
riccardoperra and arnoud-dv committed Mar 5, 2024
1 parent f366cae commit a00dc08
Show file tree
Hide file tree
Showing 11 changed files with 630 additions and 80 deletions.
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { signal } from '@angular/core'
import { Component, input, signal } from '@angular/core'
import { QueryClient } from '@tanstack/query-core'
import { TestBed } from '@angular/core/testing'
import { describe, expect, test, vi } from 'vitest'
import { By } from '@angular/platform-browser'
import { JsonPipe } from '@angular/common'
import { injectMutation } from '../inject-mutation'
import { injectMutationState } from '../inject-mutation-state'
import { provideAngularQuery } from '../providers'
import { successMutator } from './test-utils'
import { setFixtureSignalInputs, successMutator } from './test-utils'

const MUTATION_DURATION = 1000

const resolveMutations = () => vi.advanceTimersByTimeAsync(MUTATION_DURATION)

describe('injectMutationState', () => {
let queryClient: QueryClient
Expand Down Expand Up @@ -104,5 +110,68 @@ describe('injectMutationState', () => {

expect(mutationState()[0]?.variables).toEqual(variables)
})

test('should support required signal inputs', async () => {
queryClient.clear()
const fakeName = 'name1'
const mutationKey1 = ['fake', fakeName]

const mutations = TestBed.runInInjectionContext(() => {
return [
injectMutation(() => ({
mutationKey: mutationKey1,
mutationFn: () => Promise.resolve('myValue'),
})),
injectMutation(() => ({
mutationKey: mutationKey1,
mutationFn: () => Promise.reject('myValue2'),
})),
]
})

mutations.forEach((mutation) => mutation.mutate())

@Component({
selector: 'app-fake',
template: `
@for (mutation of mutationState(); track mutation) {
<span>{{ mutation.status }}</span>
}
`,
standalone: true,
imports: [JsonPipe],
})
class FakeComponent {
name = input.required<string>()

mutationState = injectMutationState(() => ({
filters: {
mutationKey: ['fake', this.name()],
exact: true,
},
}))
}

const fixture = TestBed.createComponent(FakeComponent)
const { debugElement } = fixture
setFixtureSignalInputs(fixture, { name: fakeName })

fixture.detectChanges()

let spans = debugElement
.queryAll(By.css('span'))
.map((span) => span.nativeNode.textContent)

expect(spans).toEqual(['pending', 'pending'])

await resolveMutations()
fixture.detectChanges()

spans = debugElement
.queryAll(By.css('span'))
.map((span) => span.nativeNode.textContent)

expect(spans).toEqual(['success', 'error'])
})
})
})
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { signal } from '@angular/core'
import { Component, input, signal } from '@angular/core'
import { QueryClient } from '@tanstack/query-core'
import { TestBed } from '@angular/core/testing'
import { expect, test, vi } from 'vitest'
import { describe, expect, test, vi } from 'vitest'
import { By } from '@angular/platform-browser'
import { injectMutation } from '../inject-mutation'
import { provideAngularQuery } from '../providers'
import { errorMutator, expectSignals, successMutator } from './test-utils'
import {
errorMutator,
expectSignals,
setFixtureSignalInputs,
successMutator,
} from './test-utils'

const MUTATION_DURATION = 1000

Expand Down Expand Up @@ -303,4 +309,97 @@ describe('injectMutation', () => {
expect(onSettledOnFunction).toHaveBeenCalledTimes(1)
})
})

test('should support required signal inputs', async () => {
const mutationCache = queryClient.getMutationCache()

@Component({
selector: 'app-fake',
template: `
<button (click)="mutate()"></button>
<span>{{ mutation.data() }}</span>
`,
standalone: true,
})
class FakeComponent {
name = input.required<string>()

mutation = injectMutation(() => ({
mutationKey: ['fake', this.name()],
mutationFn: () => successMutator(this.name()),
}))

mutate(): void {
this.mutation.mutate()
}
}

const fixture = TestBed.createComponent(FakeComponent)
const { debugElement } = fixture
setFixtureSignalInputs(fixture, { name: 'value' })

const button = debugElement.query(By.css('button'))
button.triggerEventHandler('click')

await resolveMutations()
fixture.detectChanges()

const text = debugElement.query(By.css('span')).nativeElement.textContent
expect(text).toEqual('value')
const mutation = mutationCache.find({ mutationKey: ['fake', 'value'] })
expect(mutation).toBeDefined()
expect(mutation!.options.mutationKey).toStrictEqual(['fake', 'value'])
})

test('should update options on required signal input change', async () => {
const mutationCache = queryClient.getMutationCache()

@Component({
selector: 'app-fake',
template: `
<button (click)="mutate()"></button>
<span>{{ mutation.data() }}</span>
`,
standalone: true,
})
class FakeComponent {
name = input.required<string>()

mutation = injectMutation(() => ({
mutationKey: ['fake', this.name()],
mutationFn: () => successMutator(this.name()),
}))

mutate(): void {
this.mutation.mutate()
}
}

const fixture = TestBed.createComponent(FakeComponent)
const { debugElement } = fixture
setFixtureSignalInputs(fixture, { name: 'value' })

const button = debugElement.query(By.css('button'))
const span = debugElement.query(By.css('span'))

button.triggerEventHandler('click')
await resolveMutations()
fixture.detectChanges()

expect(span.nativeElement.textContent).toEqual('value')

setFixtureSignalInputs(fixture, { name: 'updatedValue' })

button.triggerEventHandler('click')
await resolveMutations()
fixture.detectChanges()

expect(span.nativeElement.textContent).toEqual('updatedValue')

const mutations = mutationCache.findAll()
expect(mutations.length).toBe(2)
const [mutation1, mutation2] = mutations
expect(mutation1!.options.mutationKey).toEqual(['fake', 'value'])
expect(mutation2!.options.mutationKey).toEqual(['fake', 'updatedValue'])
})
})
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { computed, signal } from '@angular/core'
import { Component, computed, input, signal } from '@angular/core'
import { TestBed, fakeAsync, flush, tick } from '@angular/core/testing'
import { QueryClient } from '@tanstack/query-core'
import { expect, vi } from 'vitest'
import { describe, expect, vi } from 'vitest'
import { injectQuery } from '../inject-query'
import { provideAngularQuery } from '../providers'
import {
delayedFetcher,
getSimpleFetcherWithReturnData,
rejectFetcher,
setSignalInputs,
simpleFetcher,
} from './test-utils'

Expand Down Expand Up @@ -215,4 +216,32 @@ describe('injectQuery', () => {

expect(query.status()).toBe('error')
}))

test('should render with required signal inputs', fakeAsync(async () => {
@Component({
selector: 'app-fake',
template: `{{ query.data() }}`,
standalone: true,
})
class FakeComponent {
name = input.required<string>()

query = injectQuery(() => ({
queryKey: ['fake', this.name()],
queryFn: () => Promise.resolve(this.name()),
}))
}

const fixture = TestBed.createComponent(FakeComponent)
setSignalInputs(fixture.componentInstance, {
name: 'signal-input-required-test',
})

flush()
await fixture.detectChanges()

expect(fixture.debugElement.nativeElement.textContent).toEqual(
'signal-input-required-test',
)
}))
})
49 changes: 48 additions & 1 deletion packages/angular-query-experimental/src/__tests__/test-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { isSignal, untracked } from '@angular/core'
import { type InputSignal, isSignal, untracked } from '@angular/core'
import { SIGNAL, signalSetFn } from '@angular/core/primitives/signals'
import type { ComponentFixture } from '@angular/core/testing'

export function simpleFetcher(): Promise<string> {
return new Promise((resolve) => {
Expand Down Expand Up @@ -82,3 +84,48 @@ export const expectSignals = <T extends Record<string, any>>(
): void => {
expect(evaluateSignals(obj)).toMatchObject(expected)
}

type ToSignalInputUpdatableMap<T> = {
[K in keyof T as T[K] extends InputSignal<any>
? K
: never]: T[K] extends InputSignal<infer Value> ? Value : never
}

function componentHasSignalInputProperty<TProperty extends string>(
component: object,
property: TProperty,
): component is { [key in TProperty]: InputSignal<unknown> } {
return (
component.hasOwnProperty(property) && (component as any)[property][SIGNAL]
)
}

/**
* Set required signal input value to component fixture
* @see https://github.com/angular/angular/issues/54013
*/
export function setSignalInputs<T extends NonNullable<unknown>>(
component: T,
inputs: ToSignalInputUpdatableMap<T>,
) {
for (const inputKey in inputs) {
if (componentHasSignalInputProperty(component, inputKey)) {
signalSetFn(component[inputKey][SIGNAL], inputs[inputKey])
}
}
}

export function setFixtureSignalInputs<T extends NonNullable<unknown>>(
componentFixture: ComponentFixture<T>,
inputs: ToSignalInputUpdatableMap<T>,
options: { detectChanges: boolean } = { detectChanges: true },
) {
setSignalInputs(componentFixture.componentInstance, inputs)
if (options.detectChanges) {
componentFixture.detectChanges()
}
}

export async function flushQueue() {
await new Promise(setImmediate)
}
Loading

0 comments on commit a00dc08

Please sign in to comment.