diff --git a/.changeset/bright-walls-glow.md b/.changeset/bright-walls-glow.md new file mode 100644 index 0000000000..6c87c10c91 --- /dev/null +++ b/.changeset/bright-walls-glow.md @@ -0,0 +1,6 @@ +--- +'@lg-tools/validate': patch +--- + +- Adds `'@leafygreen-ui/testing-lib'` to list of external dependencies. +- Updates handling of external dependencies with glob patterns diff --git a/.changeset/fast-bananas-watch.md b/.changeset/fast-bananas-watch.md new file mode 100644 index 0000000000..17e7b97cb5 --- /dev/null +++ b/.changeset/fast-bananas-watch.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/toast': patch +--- + +Updates `updateToast` function to consistently return the new toast object diff --git a/.changeset/healthy-needles-learn.md b/.changeset/healthy-needles-learn.md new file mode 100644 index 0000000000..ffc6f8cf3d --- /dev/null +++ b/.changeset/healthy-needles-learn.md @@ -0,0 +1,8 @@ +--- +'@leafygreen-ui/leafygreen-provider': patch +'@leafygreen-ui/hooks': patch +'@leafygreen-ui/toast': patch +'@leafygreen-ui/a11y': patch +--- + +Updates test to import `renderHook` from `@leafygreen-ui/testing-lib` diff --git a/.changeset/khaki-lies-carry.md b/.changeset/khaki-lies-carry.md new file mode 100644 index 0000000000..cdb701c7cd --- /dev/null +++ b/.changeset/khaki-lies-carry.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/testing-lib': minor +--- + +Exports `waitForState`, a wrapper around `act` that returns the result of the state update callback diff --git a/.changeset/real-tomatoes-sneeze.md b/.changeset/real-tomatoes-sneeze.md new file mode 100644 index 0000000000..a0c3f8114c --- /dev/null +++ b/.changeset/real-tomatoes-sneeze.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/table': minor +--- + +Table now exposes an optional second generic type for useLeafyGreenTable that controls the type of the value diff --git a/.changeset/strange-scissors-prove.md b/.changeset/strange-scissors-prove.md new file mode 100644 index 0000000000..5621823002 --- /dev/null +++ b/.changeset/strange-scissors-prove.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/testing-lib': minor +--- + +Exports overrides for `renderHook` and `act` that will work in both a React 17 and React 18 test environment diff --git a/packages/a11y/src/A11y.spec.tsx b/packages/a11y/src/A11y.spec.tsx index 410cf6afa3..020ec39814 100644 --- a/packages/a11y/src/A11y.spec.tsx +++ b/packages/a11y/src/A11y.spec.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { renderHook } from '@testing-library/react-hooks'; import { axe } from 'jest-axe'; +import { renderHook } from '@leafygreen-ui/testing-lib'; + import { AriaLabelProps, AriaLabelPropsWithLabel } from './AriaLabelProps'; import { prefersReducedMotion, diff --git a/packages/date-picker/src/shared/context/SharedDatePickerContext.spec.tsx b/packages/date-picker/src/shared/context/SharedDatePickerContext.spec.tsx index 732d553314..6ea620c425 100644 --- a/packages/date-picker/src/shared/context/SharedDatePickerContext.spec.tsx +++ b/packages/date-picker/src/shared/context/SharedDatePickerContext.spec.tsx @@ -1,9 +1,9 @@ -import React, { PropsWithChildren } from 'react'; +import React from 'react'; import { act, waitFor } from '@testing-library/react'; -import { renderHook } from '@testing-library/react-hooks'; import { Month, newUTC } from '@leafygreen-ui/date-utils'; import { consoleOnce } from '@leafygreen-ui/lib'; +import { renderHook } from '@leafygreen-ui/testing-lib'; import { MAX_DATE, MIN_DATE } from '../constants'; @@ -17,16 +17,16 @@ import { const renderSharedDatePickerProvider = ( props?: Partial, ) => { - const { result, rerender } = renderHook< - PropsWithChildren<{}>, - SharedDatePickerContextProps - >(useSharedDatePickerContext, { - wrapper: ({ children }) => ( - - {children} - - ), - }); + const { result, rerender } = renderHook( + useSharedDatePickerContext, + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); return { result, rerender }; }; diff --git a/packages/date-picker/src/shared/hooks/useControlledValue/useControlledValue.spec.tsx b/packages/date-picker/src/shared/hooks/useControlledValue/useControlledValue.spec.tsx index a1cec55d7e..c380814afa 100644 --- a/packages/date-picker/src/shared/hooks/useControlledValue/useControlledValue.spec.tsx +++ b/packages/date-picker/src/shared/hooks/useControlledValue/useControlledValue.spec.tsx @@ -1,16 +1,21 @@ import React from 'react'; import { ChangeEventHandler } from 'react'; import { render } from '@testing-library/react'; -import { renderHook, RenderHookResult } from '@testing-library/react-hooks'; +import { RenderHookResult } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { act, renderHook } from '@leafygreen-ui/testing-lib'; + import { useControlledValue } from './useControlledValue'; const errorSpy = jest.spyOn(console, 'error'); const renderUseControlledValueHook = ( ...[valueProp, callback, initial]: Parameters> -): RenderHookResult>> => { +): RenderHookResult< + ReturnType>, + typeof valueProp +> => { const result = renderHook(v => useControlledValue(v, callback, initial), { initialProps: valueProp, }); @@ -18,7 +23,7 @@ const renderUseControlledValueHook = ( return { ...result }; }; -describe('packages/hooks/useControlledValue', () => { +describe('packages/date-picker/hooks/useControlledValue', () => { beforeEach(() => { errorSpy.mockImplementation(() => {}); }); @@ -109,7 +114,7 @@ describe('packages/hooks/useControlledValue', () => { test('setting value to undefined should keep the component controlled', () => { const { rerender, result } = renderUseControlledValueHook('apple'); expect(result.current.isControlled).toBe(true); - rerender(undefined); + act(() => rerender(undefined)); expect(result.current.isControlled).toBe(true); }); @@ -144,8 +149,10 @@ describe('packages/hooks/useControlledValue', () => { }); test('setValue updates the value', () => { - const { result } = renderUseControlledValueHook(undefined); + const { result, rerender } = + renderUseControlledValueHook(undefined); result.current.setValue('banana'); + rerender(); expect(result.current.value).toBe('banana'); }); }); diff --git a/packages/date-picker/src/shared/hooks/useControlledValue/useControlledValue.ts b/packages/date-picker/src/shared/hooks/useControlledValue/useControlledValue.ts index ffad96bf35..db1e3fe143 100644 --- a/packages/date-picker/src/shared/hooks/useControlledValue/useControlledValue.ts +++ b/packages/date-picker/src/shared/hooks/useControlledValue/useControlledValue.ts @@ -37,9 +37,11 @@ export const useControlledValue = ( // If the value prop changes from undefined to something defined, // then isControlled is set to true, // and will remain true for the life of the component - const isControlled: boolean = useMemo(() => { - return isControlled || !isUndefined(valueProp); - }, [valueProp]); + const [isControlled, setControlled] = useState(!isUndefined(valueProp)); + useEffect(() => { + setControlled(isControlled || !isUndefined(valueProp)); + }, [isControlled, valueProp]); + const wasControlled = usePrevious(isControlled); useEffect(() => { diff --git a/packages/date-picker/src/shared/hooks/useDateSegments/useDateSegments.spec.ts b/packages/date-picker/src/shared/hooks/useDateSegments/useDateSegments.spec.ts index 7a4c2e6d25..2ab50ece93 100644 --- a/packages/date-picker/src/shared/hooks/useDateSegments/useDateSegments.spec.ts +++ b/packages/date-picker/src/shared/hooks/useDateSegments/useDateSegments.spec.ts @@ -1,6 +1,5 @@ -import { renderHook } from '@testing-library/react'; - import { DateType, Month, newUTC } from '@leafygreen-ui/date-utils'; +import { renderHook } from '@leafygreen-ui/testing-lib'; import { useDateSegments } from './useDateSegments'; import { OnUpdateCallback } from './useDateSegments.types'; diff --git a/packages/hooks/src/hooks.spec.tsx b/packages/hooks/src/hooks.spec.tsx index f9ec105871..1a15480309 100644 --- a/packages/hooks/src/hooks.spec.tsx +++ b/packages/hooks/src/hooks.spec.tsx @@ -1,4 +1,6 @@ -import { act, renderHook } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; + +import { act, renderHook } from '@leafygreen-ui/testing-lib'; import { useEventListener, @@ -94,7 +96,7 @@ describe('packages/hooks', () => { describe.skip('useMutationObserver', () => {}); //eslint-disable-line jest/no-disabled-tests test('useViewportSize responds to updates in window size', async () => { - const { result, waitForNextUpdate } = renderHook(() => useViewportSize()); + const { result, rerender } = renderHook(() => useViewportSize()); const mutableWindow: { -readonly [K in keyof Window]: Window[K] } = window; const initialHeight = 360; @@ -104,10 +106,11 @@ describe('packages/hooks', () => { mutableWindow.innerWidth = initialWidth; window.dispatchEvent(new Event('resize')); - await act(waitForNextUpdate); - - expect(result?.current?.height).toBe(initialHeight); - expect(result?.current?.width).toBe(initialWidth); + rerender(); + await waitFor(() => { + expect(result?.current?.height).toBe(initialHeight); + expect(result?.current?.width).toBe(initialWidth); + }); const updateHeight = 768; const updateWidth = 1024; @@ -116,10 +119,10 @@ describe('packages/hooks', () => { mutableWindow.innerWidth = updateWidth; window.dispatchEvent(new Event('resize')); - await act(waitForNextUpdate); - - expect(result?.current?.height).toBe(updateHeight); - expect(result?.current?.width).toBe(updateWidth); + await waitFor(() => { + expect(result?.current?.height).toBe(updateHeight); + expect(result?.current?.width).toBe(updateWidth); + }); }); describe('usePoller', () => { @@ -249,10 +252,10 @@ describe('packages/hooks', () => { expect(pollHandler).toHaveBeenCalledTimes(0); }); - test('when document is not visible', () => { + test('when document is not visible', async () => { const pollHandler = jest.fn(); - renderHook(() => usePoller(pollHandler)); + const { rerender } = renderHook(() => usePoller(pollHandler)); expect(pollHandler).toHaveBeenCalledTimes(1); @@ -260,15 +263,18 @@ describe('packages/hooks', () => { act(() => { document.dispatchEvent(new Event('visibilitychange')); }); - jest.advanceTimersByTime(30e3); expect(pollHandler).toHaveBeenCalledTimes(1); mutableDocument.visibilityState = 'visible'; + act(() => { document.dispatchEvent(new Event('visibilitychange')); }); + jest.advanceTimersByTime(30e3); + + rerender(pollHandler); // immediate triggers the pollHandler expect(pollHandler).toHaveBeenCalledTimes(2); @@ -308,11 +314,11 @@ describe('packages/hooks', () => { rerender(2020); expect(result.current).toEqual(42); - rerender(); + rerender(123); expect(result.current).toEqual(2020); rerender(); - expect(result.current).toEqual(2020); + expect(result.current).toEqual(123); }); }); diff --git a/packages/hooks/src/useDynamicRefs/useDynamicRefs.spec.tsx b/packages/hooks/src/useDynamicRefs/useDynamicRefs.spec.tsx index 19c22a3b65..90a878abb3 100644 --- a/packages/hooks/src/useDynamicRefs/useDynamicRefs.spec.tsx +++ b/packages/hooks/src/useDynamicRefs/useDynamicRefs.spec.tsx @@ -1,6 +1,6 @@ -import { renderHook } from '@testing-library/react-hooks'; - +// import { renderHook } from '@testing-library/react-hooks'; import { consoleOnce } from '@leafygreen-ui/lib'; +import { renderHook } from '@leafygreen-ui/testing-lib'; import { DynamicRefGetter, useDynamicRefs } from '.'; @@ -11,11 +11,13 @@ describe('packages/hooks/useDynamicRefs', () => { }); test('returns identical getter when rerendered ', () => { + const props = { prefix: 'A' }; const { result, rerender } = renderHook(v => useDynamicRefs(v), { - initialProps: { prefix: 'A' }, + initialProps: props, }); - rerender(); - expect(result.all[0]).toBe(result.all[1]); + const initialValue = result.current; + rerender(props); + expect(result.current).toStrictEqual(initialValue); }); test('returns unique getters when called with the same prefix', () => { @@ -33,11 +35,14 @@ describe('packages/hooks/useDynamicRefs', () => { test('returns unique getters when re-rendered with a different prefix', () => { // This is an edge-case, but this is the behavior we want if it happens + const props = { prefix: 'A' }; const { result, rerender } = renderHook(v => useDynamicRefs(v), { - initialProps: { prefix: 'A' }, + initialProps: props, }); - rerender({ prefix: 'B' }); - expect(result.all[0]).not.toBe(result.all[1]); + const initialValue = result.current; + const newProps = { prefix: 'B' }; + rerender(newProps); + expect(result.current).not.toBe(initialValue); }); describe('ref getter function', () => { @@ -66,18 +71,20 @@ describe('packages/hooks/useDynamicRefs', () => { }); test('returns identical refs when called with the same key', () => { - const { result } = renderHook(() => useDynamicRefs({ prefix: 'A' })); + const props = { prefix: 'A' }; + const { result } = renderHook(() => useDynamicRefs(props)); const ref1 = result.current('key'); const ref2 = result.current('key'); expect(ref1).toBe(ref2); }); test('returns identical refs when rerendered', () => { + const props = { prefix: 'A' }; const { result, rerender } = renderHook(v => useDynamicRefs(v), { - initialProps: { prefix: 'A' }, + initialProps: props, }); const ref1 = result.current('key'); - rerender(); + rerender(props); const ref2 = result.current('key'); expect(ref1).toBe(ref2); }); diff --git a/packages/leafygreen-provider/src/PopoverContext/PopoverContext.spec.tsx b/packages/leafygreen-provider/src/PopoverContext/PopoverContext.spec.tsx index 1f53ec961c..00b3d8dd18 100644 --- a/packages/leafygreen-provider/src/PopoverContext/PopoverContext.spec.tsx +++ b/packages/leafygreen-provider/src/PopoverContext/PopoverContext.spec.tsx @@ -1,6 +1,7 @@ import React, { PropsWithChildren } from 'react'; import { act, fireEvent, render, waitFor } from '@testing-library/react'; -import { renderHook } from '@testing-library/react-hooks'; + +import { renderHook } from '@leafygreen-ui/testing-lib'; import { PopoverProvider, type PopoverState, usePopoverContext } from '.'; diff --git a/packages/table/src/useLeafyGreenTable/TableHeaderCheckbox.tsx b/packages/table/src/useLeafyGreenTable/TableHeaderCheckbox.tsx index 8efe2654cb..7d8c957c40 100644 --- a/packages/table/src/useLeafyGreenTable/TableHeaderCheckbox.tsx +++ b/packages/table/src/useLeafyGreenTable/TableHeaderCheckbox.tsx @@ -10,10 +10,10 @@ import { useRowContext } from '../Row/RowContext'; import { disabledTableRowCheckStyles } from './useLeafyGreenTable.styles'; import { LGRowData, LGTableDataType } from '.'; -export const TableHeaderCheckbox = ({ +export const TableHeaderCheckbox = ({ table, }: { - table: Table>; + table: Table>; }) => { const { theme } = useDarkMode(); const { disabled: rowIsDisabled } = useRowContext(); diff --git a/packages/table/src/useLeafyGreenTable/TableRowCheckbox.tsx b/packages/table/src/useLeafyGreenTable/TableRowCheckbox.tsx index d0557ed2a4..c16551ca12 100644 --- a/packages/table/src/useLeafyGreenTable/TableRowCheckbox.tsx +++ b/packages/table/src/useLeafyGreenTable/TableRowCheckbox.tsx @@ -10,12 +10,12 @@ import { useRowContext } from '../Row/RowContext'; import { disabledTableRowCheckStyles } from './useLeafyGreenTable.styles'; import { LGRowData, LGTableDataType } from '.'; -export const TableRowCheckbox = ({ +export const TableRowCheckbox = ({ row, table, }: { - table: Table>; - row: Row>; + table: Table>; + row: Row>; }) => { const { theme } = useDarkMode(); const { disabled: rowIsDisabled } = useRowContext(); diff --git a/packages/table/src/useLeafyGreenTable/useLeafyGreenTable.tsx b/packages/table/src/useLeafyGreenTable/useLeafyGreenTable.tsx index b1ebcc62d4..7070e7f751 100644 --- a/packages/table/src/useLeafyGreenTable/useLeafyGreenTable.tsx +++ b/packages/table/src/useLeafyGreenTable/useLeafyGreenTable.tsx @@ -15,17 +15,7 @@ import { LeafyGreenTable, LGColumnDef, LGTableDataType } from '.'; const CHECKBOX_WIDTH = 14; -/** - * A `ColumnDef` object injected into `useReactTable`'s `columns` option when the user is using selectable rows. - */ -const baseSelectColumnConfig: LGColumnDef = { - id: 'select', - size: CHECKBOX_WIDTH, - header: TableHeaderCheckbox, - cell: TableRowCheckbox, -}; - -function useLeafyGreenTable({ +function useLeafyGreenTable({ containerRef, data, columns: columnsProp, @@ -34,7 +24,17 @@ function useLeafyGreenTable({ useVirtualScrolling = false, allowSelectAll = true, ...rest -}: LeafyGreenTableOptions): LeafyGreenTable { +}: LeafyGreenTableOptions): LeafyGreenTable { + /** + * A `ColumnDef` object injected into `useReactTable`'s `columns` option when the user is using selectable rows. + */ + const baseSelectColumnConfig: LGColumnDef = { + id: 'select', + size: CHECKBOX_WIDTH, + header: TableHeaderCheckbox, + cell: TableRowCheckbox, + }; + const hasSortableColumns = React.useMemo( () => columnsProp.some(propCol => !!propCol.enableSorting), [columnsProp], @@ -42,15 +42,15 @@ function useLeafyGreenTable({ const selectColumnConfig = allowSelectAll ? baseSelectColumnConfig : omit(baseSelectColumnConfig, 'header'); - const columns = React.useMemo>>( + const columns = React.useMemo>>( () => [ - ...(hasSelectableRows ? [selectColumnConfig as LGColumnDef] : []), + ...(hasSelectableRows ? [selectColumnConfig as LGColumnDef] : []), ...columnsProp.map(propColumn => { return { ...propColumn, align: propColumn.align ?? 'left', enableSorting: propColumn.enableSorting ?? false, - } as LGColumnDef; + } as LGColumnDef; }), ], [columnsProp, hasSelectableRows, selectColumnConfig], diff --git a/packages/table/src/useLeafyGreenTable/useLeafyGreenTable.types.ts b/packages/table/src/useLeafyGreenTable/useLeafyGreenTable.types.ts index 0556a31eaa..f8cef2f587 100644 --- a/packages/table/src/useLeafyGreenTable/useLeafyGreenTable.types.ts +++ b/packages/table/src/useLeafyGreenTable/useLeafyGreenTable.types.ts @@ -31,17 +31,22 @@ export type LeafyGreenTableCell = Cell< export interface LeafyGreenTableRow extends Row> {} -export type LGColumnDef = ColumnDef> & { +export type LGColumnDef< + T extends LGRowData, + V extends unknown = unknown, +> = ColumnDef, V> & { align?: HTMLElementProps<'td'>['align']; }; /** LeafyGreen extension of `useReactTable` {@link TableOptions}*/ -export interface LeafyGreenTableOptions - extends Omit>, 'getCoreRowModel'> { +export interface LeafyGreenTableOptions< + T extends LGRowData, + V extends unknown = unknown, +> extends Omit>, 'getCoreRowModel'> { containerRef: RefObject; hasSelectableRows?: boolean; useVirtualScrolling?: boolean; - columns: Array>; + columns: Array>; withPagination?: boolean; allowSelectAll?: boolean; } diff --git a/packages/testing-lib/package.json b/packages/testing-lib/package.json index e2568db3a4..e4f59a42bb 100644 --- a/packages/testing-lib/package.json +++ b/packages/testing-lib/package.json @@ -16,13 +16,18 @@ "@testing-library/user-event": "13.5.0", "lodash": "^4.17.21" }, + "devDependencies": { + "@lg-tools/build": "0.3.1" + }, "peerDependencies": { - "@lg-tools/test": "0.3.2" + "@testing-library/react": "^12.0.0 || ^13.1.0 || ^14.0.0" + }, + "optionalDependencies": { + "@testing-library/react-hooks": ">=3.7.0" }, "scripts": { - "build": "lg build-package", - "tsc": "lg build-ts", - "docs": "lg build-tsdoc" + "build": "lg-internal-build-package", + "tsc": "tsc --build tsconfig.json" }, "license": "Apache-2.0", "publishConfig": { diff --git a/packages/testing-lib/src/RTLOverrides.ts b/packages/testing-lib/src/RTLOverrides.ts new file mode 100644 index 0000000000..2562f60e0a --- /dev/null +++ b/packages/testing-lib/src/RTLOverrides.ts @@ -0,0 +1,36 @@ +import * as RTL from '@testing-library/react'; + +/** + * Utility type that returns `X.Y` if it exists, otherwise defaults to fallback type `Z`, or `any` + */ +export type Exists< + X, + Y extends keyof X | string, + Z = unknown, +> = Y extends keyof X ? X[Y] : Z; + +/** + * Re-exports `renderHook` from `"@testing-library/react"` if it exists, + * or from `"@testing-library/react-hooks"` + * + * (used when running in a React 17 test environment) + */ +export const renderHook: Exists = + (RTL as any).renderHook ?? + (() => { + const RHTL = require('@testing-library/react-hooks'); + return RHTL.renderHook; + })(); + +/** + * Re-exports `act` from `"@testing-library/react"` if it exists, + * or from `"@testing-library/react-hooks"` + * + * (used when running in a React 17 test environment) + */ +export const act: Exists = + RTL.act ?? + (() => { + const RHTL = require('@testing-library/react-hooks'); + return RHTL.act; + })(); diff --git a/packages/testing-lib/src/index.ts b/packages/testing-lib/src/index.ts index dc9301d6b5..6d8ae4de56 100644 --- a/packages/testing-lib/src/index.ts +++ b/packages/testing-lib/src/index.ts @@ -1,6 +1,9 @@ import * as Context from './context'; import * as jest from './jest'; import * as JestDOM from './jest-dom'; +export { act, renderHook } from './RTLOverrides'; +export { waitForState } from './waitForState'; + export { Context, jest, JestDOM }; export { eventContainingTargetValue } from './eventContainingTargetValue'; diff --git a/packages/testing-lib/src/waitForState.ts b/packages/testing-lib/src/waitForState.ts new file mode 100644 index 0000000000..b7f0b67c8c --- /dev/null +++ b/packages/testing-lib/src/waitForState.ts @@ -0,0 +1,19 @@ +import { act } from './RTLOverrides'; + +/** + * Wrapper around `act`. + * + * Awaits an `act` call, + * and returns the value of the state update callback + */ +export const waitForState = async ( + callback: () => T, +): Promise => { + let val: T; + await act(() => { + val = callback(); + }); + + // @ts-expect-error - val is returned before TS sees it as being defined + return val; +}; diff --git a/packages/testing-lib/tsconfig.json b/packages/testing-lib/tsconfig.json index 9f2d965a63..edb1c03f02 100644 --- a/packages/testing-lib/tsconfig.json +++ b/packages/testing-lib/tsconfig.json @@ -6,7 +6,6 @@ "rootDir": "src", "baseUrl": ".", "paths": { - "@leafygreen-ui/icon/dist/*": ["../icon/src/generated/*"], "@leafygreen-ui/*": ["../*/src"] } }, diff --git a/packages/toast/src/ToastContext/ToastReducer/useToastReducer.spec.ts b/packages/toast/src/ToastContext/ToastReducer/useToastReducer.spec.ts index a8d8e631f0..b79aa3f886 100644 --- a/packages/toast/src/ToastContext/ToastReducer/useToastReducer.spec.ts +++ b/packages/toast/src/ToastContext/ToastReducer/useToastReducer.spec.ts @@ -1,6 +1,7 @@ import { act } from 'react-dom/test-utils'; import { cleanup } from '@testing-library/react'; -import { renderHook } from '@testing-library/react-hooks'; + +import { renderHook } from '@leafygreen-ui/testing-lib'; import { Variant } from '../../Toast.types'; import { ToastId, ToastStack } from '../ToastContext.types'; diff --git a/packages/toast/src/ToastContext/ToastReducer/useToastReducer.ts b/packages/toast/src/ToastContext/ToastReducer/useToastReducer.ts index 6cf56148dc..501c87657a 100644 --- a/packages/toast/src/ToastContext/ToastReducer/useToastReducer.ts +++ b/packages/toast/src/ToastContext/ToastReducer/useToastReducer.ts @@ -113,16 +113,25 @@ export const useToastReducer = (initialValue?: ToastStack) => { const updateToast: ToastContextProps['updateToast'] = useCallback( (id: ToastId, props: Partial) => { - dispatch({ + const action: ToastReducerAction = { type: ToastReducerActionType.Update, payload: { id, props, }, - }); - return getToast(id); + }; + + dispatch(action); + + // `getToast` will return the previous toast value, + // so we need to apply the state change manually + // in order to return the updated value + const { stack: newStack } = toastReducer({ stack }, action); + const updatedToast = newStack.get(id); + + return updatedToast; }, - [getToast], + [stack], ); const clearStack: ToastContextProps['clearStack'] = useCallback(() => { diff --git a/packages/toast/src/ToastContext/useToast/useToast.spec.tsx b/packages/toast/src/ToastContext/useToast/useToast.spec.tsx index 78b606f7b0..e3da1e5c4b 100644 --- a/packages/toast/src/ToastContext/useToast/useToast.spec.tsx +++ b/packages/toast/src/ToastContext/useToast/useToast.spec.tsx @@ -1,14 +1,11 @@ import React, { PropsWithChildren, useEffect } from 'react'; import { render } from '@testing-library/react'; -import { - cleanup, - renderHook, - RenderHookResult, -} from '@testing-library/react-hooks'; +import { cleanup } from '@testing-library/react-hooks'; + +import { renderHook, waitForState } from '@leafygreen-ui/testing-lib'; import { ToastProps, Variant } from '../../Toast.types'; import { ToastContext } from '../ToastContext'; -import { ToastContextProps } from '../ToastContext.types'; import { useToastReducer } from '../ToastReducer'; import { makeToast, @@ -56,63 +53,83 @@ describe('packages/toast/useToast', () => { }); describe('returned functions return correct values', () => { - let current: RenderHookResult< - unknown, - ToastContextProps - >['result']['current']; - - beforeEach(() => { - const { result } = renderHook(useToast, { wrapper: ToastProviderMock }); - current = result.current; - }); - - test('pushToast => ToastId', () => { - const { pushToast } = current; - const toastId = pushToast({ title: 'test' }); + test('pushToast => ToastId', async () => { + const { result } = renderHook(useToast, { + wrapper: ToastProviderMock, + }); + const { pushToast } = result.current; + const toastId = await waitForState(() => pushToast({ title: 'test' })); expect(toastId).toEqual(expect.stringContaining('toast-')); }); - test('getToast => ToastProps', () => { - const { pushToast, getToast } = current; - const toastId = pushToast({ title: 'test' }); + test('getToast => ToastProps', async () => { + const { result, rerender } = renderHook(useToast, { + wrapper: ToastProviderMock, + }); + const { pushToast, getToast } = result.current; + + const toastId = await waitForState(() => pushToast({ title: 'test' })); + rerender(); expect(getToast(toastId)).toEqual( expect.objectContaining({ title: 'test' }), ); }); - test('updateToast => ToastProps', () => { - const { pushToast, updateToast } = current; - const toastId = pushToast({ - title: 'test', - variant: Variant.Progress, - progress: 0, + test('updateToast => ToastProps', async () => { + const { result, rerender } = renderHook(useToast, { + wrapper: ToastProviderMock, + }); + const { pushToast, updateToast } = result.current; + const toastId = await waitForState(() => + pushToast({ + title: 'test', + variant: Variant.Progress, + progress: 0, + }), + ); + const updatedToast = await waitForState(() => { + rerender(); + return updateToast(toastId, { + progress: 0.5, + }); }); - expect(updateToast(toastId, { progress: 0.5 })).toEqual( + expect(updatedToast).toEqual( expect.objectContaining({ progress: 0.5 }), ); }); - test('popToast => ToastProps', () => { - const { pushToast, popToast } = current; - const toastId = pushToast({ title: 'test' }); - expect(popToast(toastId)).toEqual( - expect.objectContaining({ title: 'test' }), - ); + test('popToast => ToastProps', async () => { + const { result, rerender } = renderHook(useToast, { + wrapper: ToastProviderMock, + }); + const { pushToast, popToast } = result.current; + const toastId = await waitForState(() => pushToast({ title: 'test' })); + rerender(); + const poppedToast = await waitForState(() => popToast(toastId)); + expect(poppedToast).toEqual(expect.objectContaining({ title: 'test' })); }); - test('getStack => ToastStack (Map)', () => { - const { pushToast, getStack } = current; - pushToast({ title: 'test' }); + test('getStack => ToastStack (Map)', async () => { + const { result, rerender } = renderHook(useToast, { + wrapper: ToastProviderMock, + }); + const { pushToast, getStack } = result.current; + await waitForState(() => pushToast({ title: 'test' })); + rerender(); expect(getStack()).toBeDefined(); expect(getStack()?.size).toEqual(1); }); - test('clearStack => void', () => { - const { pushToast, clearStack, getStack } = current; - pushToast({ title: 'test' }); - expect(clearStack()).toBeUndefined(); + test('clearStack => void', async () => { + const { result } = renderHook(useToast, { + wrapper: ToastProviderMock, + }); + const { pushToast, clearStack, getStack } = result.current; + await waitForState(() => pushToast({ title: 'test' })); + const clearedStack = await waitForState(() => clearStack()); + expect(clearedStack).toBeUndefined(); expect(getStack()?.size).toEqual(0); }); }); diff --git a/tools/test/package.json b/tools/test/package.json index 31e827c99e..2561068ccf 100644 --- a/tools/test/package.json +++ b/tools/test/package.json @@ -19,6 +19,7 @@ "@emotion/server": "11.11.0", "@lg-tools/build": "0.3.1", "@lg-tools/meta": "0.1.5", + "@leafygreen-ui/testing-lib": "^0.3.4", "@testing-library/dom": "9.3.1", "@testing-library/jest-dom": "5.17.0", "@testing-library/react": "14.0.0", diff --git a/tools/validate/src/dependencies/config.ts b/tools/validate/src/dependencies/config.ts index 5fc01cf7d5..421ff2ae26 100644 --- a/tools/validate/src/dependencies/config.ts +++ b/tools/validate/src/dependencies/config.ts @@ -38,34 +38,38 @@ export const ignoreFilePatterns: Array = [ ]; /** - * These dependencies will be ignored when listed in a package.json. * These are globally available dev dependencies. - * We don't want every component flagged for not having - * these packages explicitly declared in its package.json + * + * Packages that omit these dependencies will not be flagged for missing dependencies. + * + * Packages that list these dependencies will not be flagged for unused dependencies */ -export const ignoreDependencies = [ - '@leafygreen-ui/mongo-nav', +export const externalDependencies = [ '@babel/*', '@emotion/*', + '@leafygreen-ui/mongo-nav', + '@leafygreen-ui/testing-lib', '@rollup/*', '@storybook/*', '@svgr/*', '@testing-library/*', '@types/*', - '@typescript-*', + '@typescript-eslint/*', 'buffer', 'eslint*', - 'jest*', + 'jest', + 'jest-*', 'jest-axe', 'prettier*', 'prop-types', 'react-*', 'rollup*', 'storybook-*', + 'typescript', '*-loader', '*-lint*', ]; export const depcheckOptions: depcheck.Options = { - ignoreMatches: ignoreDependencies, + ignoreMatches: externalDependencies, }; diff --git a/tools/validate/src/dependencies/utils/globToRegex.ts b/tools/validate/src/dependencies/utils/globToRegex.ts new file mode 100644 index 0000000000..55c7c3359c --- /dev/null +++ b/tools/validate/src/dependencies/utils/globToRegex.ts @@ -0,0 +1,13 @@ +/** + * Returns a _very_ rough approximation of a regex pattern, + * given a glob pattern. + * + * e.g. `globToRegex('@leafygreen-ui/*') // /@leafygreen-ui\/.+/` + * + * @internal + */ +export const globToRegex = (globPattern: string): RegExp => { + const regexPattern = globPattern.replace('*', '.+').replace('/', '\\/'); + const regEx = new RegExp(regexPattern); + return regEx; +}; diff --git a/tools/validate/src/dependencies/utils/index.ts b/tools/validate/src/dependencies/utils/index.ts index 5230b90160..9df9de2655 100644 --- a/tools/validate/src/dependencies/utils/index.ts +++ b/tools/validate/src/dependencies/utils/index.ts @@ -7,7 +7,7 @@ import path from 'path'; import { devFilePatterns, - ignoreDependencies, + externalDependencies, ignoreFilePatterns, } from '../config'; @@ -106,7 +106,7 @@ export const isDependencyUsedInSourceFile = ( importedPackages: depcheck.Results['using'], ): boolean => { // consider a dependency used in a package file if its in `ignoreMatches` - const isIgnored = ignoreDependencies.includes(depName); + const isIgnored = externalDependencies.includes(depName); const usedInPackageFile = importedPackages?.[depName]?.some( // is used in at least one... // file that is not ignored diff --git a/tools/validate/src/dependencies/validateListedDependencies.ts b/tools/validate/src/dependencies/validateListedDependencies.ts index 9aa9797f33..3af60c4fe8 100644 --- a/tools/validate/src/dependencies/validateListedDependencies.ts +++ b/tools/validate/src/dependencies/validateListedDependencies.ts @@ -5,7 +5,8 @@ import path from 'path'; import { ValidateCommandOptions } from '../validate.types'; -import { DepCheckFunctionProps, ignoreDependencies } from './config'; +import { globToRegex } from './utils/globToRegex'; +import { DepCheckFunctionProps, externalDependencies } from './config'; import { isDependencyOnlyUsedInTestFile, sortDependenciesByUsage, @@ -38,16 +39,18 @@ export function validateListedDependencies( const listedButOnlyUsedAsDev = listedDependencies.filter( listedDepName => !importedPackagesInSourceFile.includes(listedDepName) && - !ignoreDependencies.includes(listedDepName), + !externalDependencies.some(glob => { + const regEx = globToRegex(glob); + return regEx.test(listedDepName); + }), ); - verbose && + if (listedButOnlyUsedAsDev.length && verbose) { console.log( `${chalk.blue( pkgName, )}: lists packages as dependency, but only uses them in test files`, ); - verbose && console.log( listedButOnlyUsedAsDev .map( @@ -60,6 +63,7 @@ export function validateListedDependencies( ) .join('\n'), ); + } return listedButOnlyUsedAsDev; } diff --git a/yarn.lock b/yarn.lock index 9af9789a06..8f1def6e4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4189,7 +4189,7 @@ lodash "^4.17.15" redent "^3.0.0" -"@testing-library/react-hooks@8.0.1": +"@testing-library/react-hooks@8.0.1", "@testing-library/react-hooks@>=3.7.0": version "8.0.1" resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12" integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==