diff --git a/.changeset/chilly-students-design.md b/.changeset/chilly-students-design.md new file mode 100644 index 00000000..47513d9e --- /dev/null +++ b/.changeset/chilly-students-design.md @@ -0,0 +1,5 @@ +--- +'@giphy/react-components': major +--- + +Upgrading Grid from React Classes to React Hooks diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/packages/react-components/cypress/stories/emoji-variations-list.spec.tsx b/packages/react-components/cypress/stories/emoji-variations-list.spec.tsx index 10b097a8..60fbf756 100644 --- a/packages/react-components/cypress/stories/emoji-variations-list.spec.tsx +++ b/packages/react-components/cypress/stories/emoji-variations-list.spec.tsx @@ -1,7 +1,7 @@ import { composeStories } from '@storybook/testing-react' import * as React from 'react' -import { EmojiVariationsList, Grid } from '../../src' +import { EmojiVariationsList } from '../../src' import * as stories from '../../stories/emoji-variations-list.stories' import { GifTestUtilsContext, @@ -12,6 +12,7 @@ import { resetGifEventsHistory, setupGifTestUtils, } from '../utils/gif-test-utils' +import { DEFAULT_GRID_CLASS_NAME } from '../../src/components/grid' const GIFS_COUNT = 6 const EMOJI_ID = 'dalJ0CpF7hwmN1nZXe' @@ -19,7 +20,7 @@ const EMOJI_ID = 'dalJ0CpF7hwmN1nZXe' const { Default: DefaultStory } = composeStories(stories) function getGridRoot() { - return cy.get(`.${Grid.className}`) + return cy.get(`.${DEFAULT_GRID_CLASS_NAME}`) } function getGridGif(id: string) { diff --git a/packages/react-components/cypress/stories/grid.spec.tsx b/packages/react-components/cypress/stories/grid.spec.tsx index 93e74a84..65ca480c 100644 --- a/packages/react-components/cypress/stories/grid.spec.tsx +++ b/packages/react-components/cypress/stories/grid.spec.tsx @@ -1,7 +1,5 @@ import { composeStories, composeStory } from '@storybook/testing-react' import * as React from 'react' - -import { Grid } from '../../src' import * as stories from '../../stories/grid.stories' import { GifTestUtilsContext, @@ -13,6 +11,7 @@ import { setupGifTestUtils, } from '../utils/gif-test-utils' import { storiesCompositionToList } from '../utils/storybook' +import { DEFAULT_GRID_CLASS_NAME } from '../../src/components/grid' const GIFS_COUNT = 5 @@ -22,7 +21,7 @@ const composedStories = storiesCompositionToList(composeStories(stories)).filter ) function getGridRoot() { - return cy.get(`.${Grid.className}`) + return cy.get(`.${DEFAULT_GRID_CLASS_NAME}`) } function getGridGifs() { diff --git a/packages/react-components/src/components/grid.tsx b/packages/react-components/src/components/grid.tsx index 20be8da4..e66c0add 100644 --- a/packages/react-components/src/components/grid.tsx +++ b/packages/react-components/src/components/grid.tsx @@ -1,8 +1,7 @@ -import styled from '@emotion/styled' +import React, { useEffect, useCallback, useRef, useMemo, useReducer } from 'react' import { gifPaginator, GifsResult } from '@giphy/js-fetch-api' import { IGif, IUser } from '@giphy/js-types' import { getGifHeight } from '@giphy/js-util' -import React, { ElementType, GetDerivedStateFromProps, PureComponent } from 'react' import { debounce } from 'throttle-debounce' import Observer from '../util/observer' import FetchError from './fetch-error' @@ -12,17 +11,19 @@ import MasonryGrid from './masonry-grid' import PingbackContextManager from './pingback-context-manager' import type { GifOverlayProps } from './types' +export const DEFAULT_GRID_CLASS_NAME = 'giphy-grid' + type Props = { className?: string width: number - user: Partial + user?: Partial columns: number - gutter: number + gutter?: number layoutType?: 'GRID' | 'MIXED' fetchGifs: (offset: number) => Promise onGifsFetched?: (gifs: IGif[]) => void onGifsFetchError?: (e: Error) => void - overlay?: ElementType + overlay?: React.ElementType hideAttribution?: boolean noLink?: boolean noResultsMessage?: string | JSX.Element @@ -33,14 +34,10 @@ type Props = { borderRadius?: number tabIndex?: number loaderConfig?: IntersectionObserverInit - loader?: ElementType + loader?: React.ElementType } & EventProps -const Loader = styled.div<{ isFirstLoad: boolean }>` - opacity: ${(props) => (props.isFirstLoad ? 0 : 1)}; -` - -const defaultProps = Object.freeze({ gutter: 6, user: {}, initialGifs: [] }) +const FETCH_DEBOUNCE = 250 type State = { gifWidth: number @@ -51,162 +48,173 @@ type State = { isDoneFetching: boolean } -const initialState = Object.freeze({ +type Action = + | { type: 'START_FETCH' } + | { type: 'FETCH_SUCCESS'; gifs: IGif[] } + | { type: 'FETCH_FAILURE'; error: Error } + | { type: 'LOADER_VISIBLE'; isVisible: boolean } + | { type: 'DONE_FETCHING' } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'START_FETCH': + return { ...state, isFetching: true, isError: false } + case 'FETCH_SUCCESS': + return { ...state, gifs: action.gifs, isFetching: false } + case 'FETCH_FAILURE': + return { ...state, isFetching: false, isError: true } + case 'LOADER_VISIBLE': + return { ...state, isLoaderVisible: action.isVisible } + case 'DONE_FETCHING': + return { ...state, isDoneFetching: true } + default: + return state + } +} + +const initialState: State = { + gifWidth: 0, isFetching: false, isError: false, - gifWidth: 0, - gifs: [] as IGif[], + gifs: [], isLoaderVisible: false, isDoneFetching: false, -}) - -class Grid extends PureComponent { - static className = 'giphy-grid' - static loaderClassName = 'loader' - static fetchDebounce = 250 - static readonly defaultProps = defaultProps - readonly state = { ...initialState, gifs: this.props.initialGifs || [] } - bricks?: any - el?: HTMLDivElement | null - unmounted: boolean = false - paginator = gifPaginator(this.props.fetchGifs, this.state.gifs) - static getDerivedStateFromProps: GetDerivedStateFromProps = ( - { columns, gutter, width }: Props, - prevState: State - ) => { +} + +const Grid = ({ + className = DEFAULT_GRID_CLASS_NAME, + width, + user = {}, + columns, + gutter = 6, + layoutType = 'GRID', + fetchGifs, + onGifsFetched, + onGifsFetchError, + overlay, + hideAttribution, + noLink, + noResultsMessage, + initialGifs = [], + useTransform, + columnOffsets, + backgroundColor, + borderRadius, + tabIndex = 0, + loaderConfig, + loader: LoaderVisual = DotsLoader, + ...gifEvents +}: Props) => { + const [state, dispatch] = useReducer(reducer, { + ...initialState, + gifs: initialGifs, + }) + const gifWidth = useMemo(() => { const gutterOffset = gutter * (columns - 1) - const gifWidth = Math.floor((width - gutterOffset) / columns) - if (prevState.gifWidth !== gifWidth) { - return { gifWidth } - } - return null - } + return Math.floor((width - gutterOffset) / columns) + }, [width, gutter, columns]) + const paginator = useRef(gifPaginator(fetchGifs, initialGifs)) + const unmounted = useRef(false) - componentDidMount() { - this.unmounted = false - this.onFetch() - } + const handleFetchGifs = useCallback( + (prefetchCount: number) => { + const debounceFetchGifs = debounce(FETCH_DEBOUNCE, async (prefetchCount: number) => { + let gifs + try { + gifs = await paginator.current() + if (unmounted.current) return + } catch (e) { + const error = e as Error + if (unmounted.current) return + dispatch({ type: 'FETCH_FAILURE', error }) + if (onGifsFetchError) onGifsFetchError(error) + return + } - componentWillUnmount() { - this.unmounted = true - } + if (gifs) { + if (prefetchCount === gifs.length) { + dispatch({ type: 'DONE_FETCHING' }) + } else { + dispatch({ type: 'FETCH_SUCCESS', gifs }) + if (onGifsFetched) onGifsFetched(gifs) + } + } + }) - onLoaderVisible = (isVisible: boolean) => { - if (this.unmounted) return - this.setState({ isLoaderVisible: isVisible }, this.onFetch) - } + if (unmounted.current) return - fetchGifs = debounce(Grid.fetchDebounce, async (prefetchCount) => { - let gifs - try { - gifs = await this.paginator() - if (this.unmounted) return - } catch (error) { - if (this.unmounted) return - this.setState({ isFetching: false, isError: true }) - const { onGifsFetchError } = this.props - if (onGifsFetchError) onGifsFetchError(error as Error) - } - if (gifs) { - // if we've just fetched and we don't have - // any more gifs, we're done fetching - if (prefetchCount === gifs.length) { - this.setState({ isDoneFetching: true }) - } else { - this.setState({ gifs, isFetching: false }) - const { onGifsFetched } = this.props - if (onGifsFetched) onGifsFetched(gifs) - this.onFetch() + debounceFetchGifs(prefetchCount) + }, + [onGifsFetched, onGifsFetchError] + ) + + const onLoaderVisible = useCallback( + (isVisible: boolean) => { + if (unmounted.current) return + dispatch({ type: 'LOADER_VISIBLE', isVisible }) + if (!state.isFetching && isVisible) { + dispatch({ type: 'START_FETCH' }) + handleFetchGifs(state.gifs.length) } - } - }) + }, + [state.gifs.length, state.isFetching, handleFetchGifs] + ) + + useEffect(() => { + unmounted.current = false + handleFetchGifs(initialGifs.length) - onFetch = async () => { - if (this.unmounted) return - const { isFetching, isLoaderVisible, gifs } = this.state - if (!isFetching && isLoaderVisible) { - this.setState({ isFetching: true, isError: false }) - this.fetchGifs(gifs.length) + return () => { + unmounted.current = true } - } + }, [handleFetchGifs]) - render() { - const { - onGifVisible, - onGifRightClick, - className = Grid.className, - onGifSeen, - onGifClick, - onGifKeyPress, - user, - overlay, - hideAttribution, - noLink, - borderRadius, - noResultsMessage, - columns, - width, - gutter, - useTransform, - columnOffsets, - backgroundColor, - loaderConfig, - tabIndex = 0, - layoutType = 'GRID', - loader: LoaderVisual = DotsLoader, - } = this.props - const { gifWidth, gifs, isError, isDoneFetching } = this.state - const showLoader = !isDoneFetching - const isFirstLoad = gifs.length === 0 - // get the height of each grid item - const itemHeights = gifs.map((gif) => getGifHeight(gif, gifWidth)) - return ( - -
- - {gifs.map((gif) => ( - - ))} - - {!showLoader && gifs.length === 0 && noResultsMessage} - {isError ? ( - - ) : ( - showLoader && ( - - - - - - ) - )} -
-
- ) - } + const { gifs, isDoneFetching, isError } = state + const itemHeights = useMemo(() => gifs.map((gif) => getGifHeight(gif, gifWidth)), [gifs, gifWidth]) + const isFirstLoad = gifs.length === 0 + const showLoader = !isDoneFetching + + return ( + +
+ + {gifs.map((gif) => ( + + ))} + + {!showLoader && gifs.length === 0 && noResultsMessage} + {isError ? ( + handleFetchGifs(state.gifs.length)} /> + ) : ( + showLoader && + !isFirstLoad && ( + + + + ) + )} +
+
+ ) } export default Grid