Skip to content
This repository has been archived by the owner on Jan 10, 2025. It is now read-only.

Commit

Permalink
Typed triggerKeypath
Browse files Browse the repository at this point in the history
  • Loading branch information
melnikov-s committed Aug 22, 2022
1 parent 705dc37 commit 84d4756
Show file tree
Hide file tree
Showing 5 changed files with 156 additions and 21 deletions.
5 changes: 5 additions & 0 deletions .changeset/honest-bikes-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/react-testing': minor
---

Fully typed triggerKeypath method
24 changes: 16 additions & 8 deletions packages/react-testing/src/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ import {
PropsFor,
UnknowablePropsFor,
DebugOptions,
TriggerKeypathParams,
TriggerKeypathReturn,
KeyPathFunction,
ExtractKeypath,
} from './types';

type Root = import('./root').Root<any>;
type Root = import('./root').Root<unknown>;

interface Tree<Props> {
tag: Tag;
Expand Down Expand Up @@ -193,26 +197,30 @@ export class Element<Props> implements Node<Props> {
trigger<K extends FunctionKeys<Props>>(
prop: K,
...args: DeepPartialArguments<Props[K]>
): ReturnType<
NonNullable<
Props[K] extends ((...args: any[]) => any) | undefined ? Props[K] : never
>
> {
): NonNullable<Props[K]> extends (...args: any[]) => any
? ReturnType<NonNullable<Props[K]>>
: never {
return this.root.act(() => {
const propValue = this.props[prop];

if (propValue == null) {
throw new Error(
`Attempted to call prop ${prop} but it was not defined.`,
`Attempted to call prop ${String(prop)} but it was not defined.`,
);
}

return (propValue as any)(...args);
});
}

triggerKeypath<T = unknown>(keypath: string, ...args: unknown[]): T {
triggerKeypath<
Path extends string,
ExtractedFunction extends KeyPathFunction = ExtractKeypath<Props, Path>,
>(
...params: TriggerKeypathParams<Props, Path, ExtractedFunction>
): TriggerKeypathReturn<Props, Path, ExtractedFunction> {
return this.root.act(() => {
const [keypath, ...args] = params;
const {props} = this;
const parts = keypath.split(/[.[\]]/g).filter(Boolean);

Expand Down
21 changes: 14 additions & 7 deletions packages/react-testing/src/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {
DeepPartialArguments,
PropsFor,
DebugOptions,
TriggerKeypathParams,
TriggerKeypathReturn,
KeyPathFunction,
ExtractKeypath,
} from './types';

// eslint-disable-next-line @typescript-eslint/no-var-requires
Expand Down Expand Up @@ -201,16 +205,19 @@ export class Root<Props> implements Node<Props> {
trigger<K extends FunctionKeys<Props>>(
prop: K,
...args: DeepPartialArguments<Props[K]>
): ReturnType<
NonNullable<
Props[K] extends ((...args: any[]) => any) | undefined ? Props[K] : never
>
> {
): NonNullable<Props[K]> extends (...args: any[]) => any
? ReturnType<NonNullable<Props[K]>>
: never {
return this.withRoot((root) => root.trigger(prop, ...(args as any)));
}

triggerKeypath<T = unknown>(keypath: string, ...args: unknown[]) {
return this.withRoot((root) => root.triggerKeypath<T>(keypath, ...args));
triggerKeypath<
Path extends string,
ExtractedFunction extends KeyPathFunction = ExtractKeypath<Props, Path>,
>(
...args: TriggerKeypathParams<Props, Path, ExtractedFunction>
): TriggerKeypathReturn<Props, Path, ExtractedFunction> {
return this.withRoot((root) => root.triggerKeypath(...args));
}

mount() {
Expand Down
1 change: 1 addition & 0 deletions packages/react-testing/src/tests/element.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ describe('Element', () => {
defaultRoot,
);

// @ts-expect-error actions is not valid parameter, because it does not point to a function
expect(() => element.triggerKeypath('actions')).toThrow(
/Value at keypath 'actions' is not a function/,
);
Expand Down
126 changes: 120 additions & 6 deletions packages/react-testing/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,116 @@
import React from 'react';

type IsNeverType<T> = [T] extends [never] ? true : false;
type Rest<T extends any[]> = T extends [any, ...infer Rest] ? Rest : never;

type AllKeys<T> = T extends any ? keyof T : never;
type PickType<T, K extends AllKeys<T>> = T extends {[k in K]?: any}
? T[K]
: undefined;
type PickTypeOf<T, K extends PropertyKey> = K extends AllKeys<T>
? PickType<T, K>
: never;

type Merge<T> = {[K in keyof T]: PickTypeOf<T, K>} & {
[K in Exclude<AllKeys<T>, keyof T>]?: PickTypeOf<T, K>;
};

type IsArrayIndex<T extends string> = T extends `${number | string}`
? true
: T extends `${infer Head}${infer Rest}`
? Head extends '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
? Rest extends ''
? true
: IsArrayIndex<Rest>
: false
: false;

type ExtractProperty<Props, PropKeys extends any[]> = Merge<
Extract<Props, object>
> extends infer MergedProps
? PropKeys[0] extends undefined
? Props
: PropKeys[0] extends keyof MergedProps
? IfNever<
Extract<MergedProps[PropKeys[0]], ReadonlyArray<any>>,
unknown
> extends ReadonlyArray<infer ArrayType>
? PropKeys[1] extends undefined
? MergedProps[PropKeys[0]]
: IsArrayIndex<PropKeys[1]> extends true
? ExtractProperty<ArrayType, Rest<Rest<PropKeys>>>
: never
: ExtractProperty<MergedProps[PropKeys[0]], Rest<PropKeys>>
: never
: never;

type Split<
String extends string,
Delimiter extends string,
> = string extends String
? string[]
: String extends ''
? []
: String extends `${infer T}${Delimiter}${infer U}`
? [T, ...Split<U, Delimiter>]
: [String];

type NormalizeKeypath<Path extends string> =
Path extends `${infer A}.[${infer B}].${infer C}`
? NormalizeKeypath<`${A}.${B}.${C}`>
: Path extends `${infer A}[${infer B}].${infer C}`
? NormalizeKeypath<`${A}.${B}.${C}`>
: Path extends `${infer A}[${infer B}]${infer C}`
? NormalizeKeypath<`${A}.${B}.${C}`>
: Path;

export type ExtractKeypath<Props, Keypath extends string> = ExtractProperty<
Props,
Split<NormalizeKeypath<Keypath>, '.'>
> extends infer R
? R extends KeyPathFunction
? R
: never
: never;

type IfNever<C, F> = IsNeverType<C> extends true ? F : C;
type IsUnknown<T> = unknown extends T
? [T] extends [null]
? false
: true
: false;

type IsSkippedType<Props, Path extends string> = IsUnknown<Props> extends true
? true
: string extends Path
? true
: false;

export type KeyPathFunction = Function | ((...args: any[]) => any);

export type TriggerKeypathParams<
Props,
Path extends string,
ExtractedFunction extends KeyPathFunction,
> = IsSkippedType<Props, Path> extends false
? [
keypath: IsNeverType<ExtractedFunction> extends true ? never : Path,
...args: ExtractedFunction extends (...args: any[]) => any
? DeepPartialArguments<Parameters<ExtractedFunction>>
: any[],
]
: [keypath: string, ...args: unknown[]];

export type TriggerKeypathReturn<
Props,
Path extends string,
ExtractedFunction extends KeyPathFunction,
> = IsSkippedType<Props, Path> extends false
? ExtractedFunction extends (...args: any[]) => any
? ReturnType<ExtractedFunction>
: any
: any;

export type PropsFor<T extends string | React.ComponentType<any>> =
T extends string
? T extends keyof JSX.IntrinsicElements
Expand Down Expand Up @@ -118,12 +229,15 @@ export interface Node<Props> {
trigger<K extends FunctionKeys<Props>>(
prop: K,
...args: DeepPartialArguments<Props[K]>
): ReturnType<
NonNullable<
Props[K] extends ((...args: any[]) => any) | undefined ? Props[K] : never
>
>;
triggerKeypath<T = unknown>(keypath: string, ...args: unknown[]): T;
): NonNullable<Props[K]> extends (...args: any[]) => any
? ReturnType<NonNullable<Props[K]>>
: never;
triggerKeypath<
Path extends string,
ExtractedFunction extends KeyPathFunction = ExtractKeypath<Props, Path>,
>(
...args: TriggerKeypathParams<Props, Path, ExtractedFunction>
): TriggerKeypathReturn<Props, Path, ExtractedFunction>;

debug(options?: DebugOptions): string;
toString(): string;
Expand Down

0 comments on commit 84d4756

Please sign in to comment.