Skip to content

Commit

Permalink
feat(ui-react-core): add createContextUtilities (#4331)
Browse files Browse the repository at this point in the history
Co-authored-by: Ioana Brooks <68251134+ioanabrooks@users.noreply.github.com>
  • Loading branch information
calebpollman and ioanabrooks committed Aug 10, 2023
1 parent 2ccbb88 commit 54d884d
Show file tree
Hide file tree
Showing 12 changed files with 266 additions and 26 deletions.
7 changes: 7 additions & 0 deletions .changeset/mighty-papayas-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@aws-amplify/ui-react-core": patch
"@aws-amplify/ui-react": patch
"@aws-amplify/ui": patch
---

feat(ui-react-core): add createContextUtilities
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ Array [
"RenderNothing",
"isAuthenticatorComponentRouteKey",
"resolveAuthenticatorComponents",
"templateJoin",
"useAuthenticator",
"useAuthenticatorInitMachine",
"useAuthenticatorRoute",
Expand Down
4 changes: 2 additions & 2 deletions packages/react-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ export {

export { RenderNothing } from './components';

// components/hooks/utils
export {
useDeprecationWarning,
UseDeprecationWarning,
useHasValueUpdated,
usePreviousValue,
} from './hooks';
export { templateJoin } from './utils';

export { MergeProps } from './types';
1 change: 1 addition & 0 deletions packages/react-core/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type MergeProps<C, P> = C & Omit<P, keyof C>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';

import createContextUtilities, {
INVALID_OPTIONS_MESSAGE,
} from '../createContextUtilities';

interface Stuff {
items: Record<string, number> | undefined;
things: number | undefined;
}

const contextName = 'Stuff';
const errorMessage = '`useStuff` must be used in a `StuffProvider`';

describe('createContextUtilities', () => {
it('utility hook exposes the expected values of a defined defaultValue', () => {
const defaultValue: Stuff = { items: { '1': 1, '2': 2 }, things: 1 };

const { useContext: useStuff } = createContextUtilities({
contextName,
defaultValue,
});

const { result } = renderHook(useStuff);

expect(result.current).toStrictEqual(defaultValue);
});

it('throws an error when defaultValue is undefined and no errorMessage is provided', () => {
const defaultValue = undefined as unknown as {};

expect(() => createContextUtilities({ contextName, defaultValue })).toThrow(
INVALID_OPTIONS_MESSAGE
);
});

it('throws an error when no options are provided', () => {
// @ts-expect-error
expect(() => createContextUtilities()).toThrow(INVALID_OPTIONS_MESSAGE);
});

it('utility hook exposes expected values without a defaultValue provided', () => {
const { Provider: StuffProvider, useContext: useStuff } =
createContextUtilities<Stuff>({
contextName,
errorMessage,
});

const { result } = renderHook(useStuff, {
wrapper: (props: { children: React.ReactNode }) => (
<StuffProvider {...props} />
),
});

expect(result.current).toStrictEqual({});
});

it('utility hook throws an error when no defaultValue is provided and used outside its context', () => {
const errorMessage = 'Must be used in a `StuffProvider`';

const { useContext: useStuff } = createContextUtilities<Stuff>({
contextName,
errorMessage,
});

const { result } = renderHook(useStuff);

expect(result.error?.message).toStrictEqual(errorMessage);
});
});
8 changes: 0 additions & 8 deletions packages/react-core/src/utils/__tests__/index.spec.ts

This file was deleted.

111 changes: 111 additions & 0 deletions packages/react-core/src/utils/createContextUtilities.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import React from 'react';

import { isUndefined, isString } from '@aws-amplify/ui';

export const INVALID_OPTIONS_MESSAGE =
'an `errorMessage` or a `defaultValue` must be provided in `options`';

type OptionsWithDefaultValue<ContextType extends {}> = {
contextName: string;
defaultValue: ContextType;
};

type OptionsWithErrorMessage = {
contextName: string;
errorMessage: string;
};

type ContextOptions<ContextType extends {}> =
| OptionsWithDefaultValue<ContextType>
| OptionsWithErrorMessage;

/**
* Use a `ContextType` generic and `options` to create:
* - `Context`: React Context of type `ContextType`
* - `Provider`: React Context `Provider` component exposing the `ContextType`
* as optional props
* - `useContext`: Utility Hook exposing the values of `ContextType`
*
* @template ContextType Type definition of the Context.
* > For most use cases the keys of `ContextType` should not be optional in
* preference of explicit `undefined` to avoid having optional types on the
* Utility Hook return
*
* @param options Context utility options. Requires a `contextName`, and either
* a `defaultValue` of `ContextType` or `errorMessage` allowing for differing
* behaviors of the Utility Hook when used outside a parent `Provider`:
*
* - `defaultValue`: Ensures the Utility Hook returns a default value for
* scenarios **where the missing context values should not impact usage**
* - `errorMessage`: Ensures the Utility Hook throws an error for
* scenarios **where the missing context values should prevent** usage
*
* @returns `Context`, `Provider` Component and `useContext` Utility Hook
*
* @usage
* ```ts
* interface StuffContextType {
* things: number;
* }
*
* // with `defaultValue`
* const [StuffProvider, useStuff] = createContextUtilities<StuffContextType>({
* contextName: 'Stuff',
* defaultValue: { things: 7 }
* });
*
* // with `errorMessage`
* const [StuffProvider, useStuff] = createContextUtilities<StuffContextType>({
* contextName: 'Stuff',
* errorMessage: '`useStuff` must be used in a `StuffProvider`'
* });
* ```
*/
export default function createContextUtilities<ContextType extends {}>(
options: ContextOptions<ContextType>
): {
Provider: React.ComponentType<React.PropsWithChildren<Partial<ContextType>>>;
useContext: () => ContextType;
Context: React.Context<ContextType | undefined>;
} {
const { contextName, defaultValue, errorMessage } =
(options as {
defaultValue: ContextType;
errorMessage: string;
contextName: string;
}) ?? {};

if (isUndefined(defaultValue) && !isString(errorMessage)) {
throw new Error(INVALID_OPTIONS_MESSAGE);
}

const Context = React.createContext<ContextType | undefined>(defaultValue);

function Provider(props: React.PropsWithChildren<Partial<ContextType>>) {
const { children, ...context } = props;
const value = React.useMemo(
() => context,
// Unpack `context` for the dep array; using `[context]` results in
// evaluation on every render
// eslint-disable-next-line react-hooks/exhaustive-deps
Object.values(context)
) as ContextType;
return <Context.Provider value={value}>{children}</Context.Provider>;
}

Provider.displayName = `${contextName}Provider`;

return {
Context,
Provider,
useContext: function () {
const context = React.useContext(Context);

if (isUndefined(context)) {
throw new Error(errorMessage);
}

return context;
},
};
}
12 changes: 1 addition & 11 deletions packages/react-core/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1 @@
import { isString } from '@aws-amplify/ui';

export function templateJoin(
values: string[],
template: (value: string) => string
): string {
return values.reduce(
(acc, curr) => `${acc}${isString(curr) ? template(curr) : ''}`,
''
);
}
export { default as createContextUtilities } from './createContextUtilities';
3 changes: 1 addition & 2 deletions packages/react/src/primitives/types/view.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react';
import { MergeProps } from '@aws-amplify/ui-react-core';

import { AriaProps, BaseComponentProps } from './base';
import { BaseStyleProps } from './style';
Expand All @@ -8,8 +9,6 @@ export type IsAny<Type> = (Type extends never ? true : false) extends false
? false
: true;

type MergeProps<A, B> = A & Omit<B, keyof A>;

export type ElementType = React.ElementType;

type AsProp<Element extends ElementType> = {
Expand Down
12 changes: 10 additions & 2 deletions packages/ui/src/types/util.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
// Prevents usage of T from being automatically inferred.
// https://github.com/Microsoft/TypeScript/issues/14829#issuecomment-504042546
/**
* Prevents usage of T from being automatically inferred.
* see: https://github.com/Microsoft/TypeScript/issues/14829#issuecomment-504042546
*/
export type NoInfer<T> = [T][T extends any ? 0 : never];

/**
* Improves readability of enumerable properties of an `Object` created from another `Object`,
* for example types that have been created using `Omit` or `Pick`.
*/
export type Prettify<T> = { [K in keyof T]: T[K] } & {};

/**
Matches any [primitive value](https://developer.mozilla.org/en-US/docs/Glossary/Primitive).
Expand Down
22 changes: 22 additions & 0 deletions packages/ui/src/utils/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import {
isObject,
isSet,
isString,
isTypedFunction,
isUndefined,
sanitizeNamespaceImport,
templateJoin,
} from '..';
import { ComponentClassName, Modifiers } from '../../types';

Expand Down Expand Up @@ -314,3 +316,23 @@ describe('classNameModifierByFlag', () => {
expect(classNameModifierByFlag(myClass, modifier, false)).toEqual('');
});
});

describe('isTypedFunction', () => {
it('returns `true` when the value param is a function', () => {
expect(isTypedFunction(() => null)).toBe(true);
});

it.each(['string', null, undefined, true, false])(
'returns `false` when the value param is %s',
(value) => {
expect(isTypedFunction(value)).toBe(false);
}
);
});

describe('templateJoin', () => {
it('returns the expected value', () => {
const output = templateJoin(['one', 'two'], (value) => `^${value}^`);
expect(output).toBe('^one^^two^');
});
});
40 changes: 40 additions & 0 deletions packages/ui/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,43 @@ export const classNameModifierByFlag = (
): string => {
return flag ? `${base}--${modifier}` : '';
};

/**
* `isFunction` but types the param with its function signature
*
* @param {unknown} value param to check
* @returns {boolean} whether `value` is a function
*/
export function isTypedFunction<T extends (...args: any[]) => any>(
value: unknown
): value is T {
return isFunction(value);
}

/**
* Similar to `Array.join`, with an optional callback/template param
* for formatting returned string values
*
* @param {string[]} values string array
* @param {(value: string) => string} template callback format param
* @returns formatted string array
*/
export function templateJoin(
values: string[],
template: (value: string) => string
): string {
return values.reduce(
(acc, curr) => `${acc}${isString(curr) ? template(curr) : ''}`,
''
);
}

/**
* A function that does nothing
*
* @param {any[]} _ accepts any parameters
* @returns nothing
*/
export function noop(..._: any[]): void {
return;
}

0 comments on commit 54d884d

Please sign in to comment.