diff --git a/.changeset/mighty-papayas-care.md b/.changeset/mighty-papayas-care.md new file mode 100644 index 0000000000..f3d675e33a --- /dev/null +++ b/.changeset/mighty-papayas-care.md @@ -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 diff --git a/packages/react-core/src/__tests__/__snapshots__/index.spec.ts.snap b/packages/react-core/src/__tests__/__snapshots__/index.spec.ts.snap index 669dcb801b..6a246dbf4a 100644 --- a/packages/react-core/src/__tests__/__snapshots__/index.spec.ts.snap +++ b/packages/react-core/src/__tests__/__snapshots__/index.spec.ts.snap @@ -6,7 +6,6 @@ Array [ "RenderNothing", "isAuthenticatorComponentRouteKey", "resolveAuthenticatorComponents", - "templateJoin", "useAuthenticator", "useAuthenticatorInitMachine", "useAuthenticatorRoute", diff --git a/packages/react-core/src/index.ts b/packages/react-core/src/index.ts index 2174c3d1aa..543c197ad4 100644 --- a/packages/react-core/src/index.ts +++ b/packages/react-core/src/index.ts @@ -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'; diff --git a/packages/react-core/src/types/index.ts b/packages/react-core/src/types/index.ts new file mode 100644 index 0000000000..c1b7d98fbf --- /dev/null +++ b/packages/react-core/src/types/index.ts @@ -0,0 +1 @@ +export type MergeProps = C & Omit; diff --git a/packages/react-core/src/utils/__tests__/createContextUtilities.spec.tsx b/packages/react-core/src/utils/__tests__/createContextUtilities.spec.tsx new file mode 100644 index 0000000000..436cd0ff26 --- /dev/null +++ b/packages/react-core/src/utils/__tests__/createContextUtilities.spec.tsx @@ -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 | 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({ + contextName, + errorMessage, + }); + + const { result } = renderHook(useStuff, { + wrapper: (props: { children: React.ReactNode }) => ( + + ), + }); + + 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({ + contextName, + errorMessage, + }); + + const { result } = renderHook(useStuff); + + expect(result.error?.message).toStrictEqual(errorMessage); + }); +}); diff --git a/packages/react-core/src/utils/__tests__/index.spec.ts b/packages/react-core/src/utils/__tests__/index.spec.ts deleted file mode 100644 index 31d2fd2719..0000000000 --- a/packages/react-core/src/utils/__tests__/index.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { templateJoin } from '..'; - -describe('templateJoin', () => { - it('returns the expected value', () => { - const output = templateJoin(['one', 'two'], (value) => `^${value}^`); - expect(output).toBe('^one^^two^'); - }); -}); diff --git a/packages/react-core/src/utils/createContextUtilities.tsx b/packages/react-core/src/utils/createContextUtilities.tsx new file mode 100644 index 0000000000..a3c2d94fd4 --- /dev/null +++ b/packages/react-core/src/utils/createContextUtilities.tsx @@ -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 = { + contextName: string; + defaultValue: ContextType; +}; + +type OptionsWithErrorMessage = { + contextName: string; + errorMessage: string; +}; + +type ContextOptions = + | OptionsWithDefaultValue + | 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({ + * contextName: 'Stuff', + * defaultValue: { things: 7 } + * }); + * + * // with `errorMessage` + * const [StuffProvider, useStuff] = createContextUtilities({ + * contextName: 'Stuff', + * errorMessage: '`useStuff` must be used in a `StuffProvider`' + * }); + * ``` + */ +export default function createContextUtilities( + options: ContextOptions +): { + Provider: React.ComponentType>>; + useContext: () => ContextType; + Context: React.Context; +} { + 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(defaultValue); + + function Provider(props: React.PropsWithChildren>) { + 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 {children}; + } + + Provider.displayName = `${contextName}Provider`; + + return { + Context, + Provider, + useContext: function () { + const context = React.useContext(Context); + + if (isUndefined(context)) { + throw new Error(errorMessage); + } + + return context; + }, + }; +} diff --git a/packages/react-core/src/utils/index.ts b/packages/react-core/src/utils/index.ts index 5a3a6a561a..8518dcd116 100644 --- a/packages/react-core/src/utils/index.ts +++ b/packages/react-core/src/utils/index.ts @@ -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'; diff --git a/packages/react/src/primitives/types/view.ts b/packages/react/src/primitives/types/view.ts index 15afc02811..36782cd4e7 100644 --- a/packages/react/src/primitives/types/view.ts +++ b/packages/react/src/primitives/types/view.ts @@ -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'; @@ -8,8 +9,6 @@ export type IsAny = (Type extends never ? true : false) extends false ? false : true; -type MergeProps = A & Omit; - export type ElementType = React.ElementType; type AsProp = { diff --git a/packages/ui/src/types/util.ts b/packages/ui/src/types/util.ts index 0fdd421ad2..e0fb9baa30 100644 --- a/packages/ui/src/types/util.ts +++ b/packages/ui/src/types/util.ts @@ -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 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 = { [K in keyof T]: T[K] } & {}; + /** Matches any [primitive value](https://developer.mozilla.org/en-US/docs/Glossary/Primitive). diff --git a/packages/ui/src/utils/__tests__/index.test.ts b/packages/ui/src/utils/__tests__/index.test.ts index 89b3992fd9..85ddb576e5 100644 --- a/packages/ui/src/utils/__tests__/index.test.ts +++ b/packages/ui/src/utils/__tests__/index.test.ts @@ -12,8 +12,10 @@ import { isObject, isSet, isString, + isTypedFunction, isUndefined, sanitizeNamespaceImport, + templateJoin, } from '..'; import { ComponentClassName, Modifiers } from '../../types'; @@ -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^'); + }); +}); diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index b9167a08d8..bc34bc4465 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -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 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; +}