From a069eff0b2defc5be845ccfe5d158ea451357563 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Thu, 29 Apr 2021 23:59:28 +0200 Subject: [PATCH] Rewrite gallery to use reducer --- .gitignore | 2 +- ui/.eslintrc.js | 2 +- ui/package-lock.json | 14 +-- ui/package.json | 6 +- .../components/albumGallery/AlbumGallery.tsx | 99 +++++++------------ .../photoGallery/MediaThumbnail.tsx | 53 ++-------- .../components/photoGallery/PhotoGallery.tsx | 92 +++++++++++------ .../photoGallery/photoGalleryReducer.test.ts | 91 +++++++++++++++++ .../photoGallery/photoGalleryReducer.tsx | 74 ++++++++++++++ .../presentView/PresentNavigationOverlay.tsx | 17 ++-- .../photoGallery/presentView/PresentView.tsx | 30 +++--- ui/tsconfig.json | 3 +- 12 files changed, 309 insertions(+), 174 deletions(-) create mode 100644 ui/src/components/photoGallery/photoGalleryReducer.test.ts create mode 100644 ui/src/components/photoGallery/photoGalleryReducer.tsx diff --git a/.gitignore b/.gitignore index e24ed426..83f53b74 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ docker-compose.yml node_modules/ # testing -/coverage +/ui/coverage # building .cache/ diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js index e14b0ea0..8f6d5706 100644 --- a/ui/.eslintrc.js +++ b/ui/.eslintrc.js @@ -43,7 +43,7 @@ module.exports = { // parser: 'babel-eslint', overrides: [ Object.assign(require('eslint-plugin-jest').configs.recommended, { - files: ['**/*.test.js'], + files: ['**/*.test.js', '**/*.test.ts', '**/*.test.tsx'], env: { jest: true }, plugins: ['jest', 'jest-dom'], rules: Object.assign( diff --git a/ui/package-lock.json b/ui/package-lock.json index 221dce9e..7fba8e71 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -56,7 +56,7 @@ "@testing-library/jest-dom": "^5.11.10", "@testing-library/react": "^11.2.6", "@types/geojson": "^7946.0.7", - "@types/jest": "^26.0.22", + "@types/jest": "^26.0.23", "@types/mapbox-gl": "^2.1.1", "@types/react": "^17.0.3", "@types/react-dom": "^17.0.3", @@ -2789,9 +2789,9 @@ } }, "node_modules/@types/jest": { - "version": "26.0.22", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.22.tgz", - "integrity": "sha512-eeWwWjlqxvBxc4oQdkueW5OF/gtfSceKk4OnOAGlUSwS/liBRtZppbJuz1YkgbrbfGOoeBHun9fOvXnjNwrSOw==", + "version": "26.0.23", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.23.tgz", + "integrity": "sha512-ZHLmWMJ9jJ9PTiT58juykZpL7KjwJywFN3Rr2pTSkyQfydf/rk22yS7W8p5DaVUMQ2BQC7oYiU3FjbTM/mYrOA==", "dev": true, "dependencies": { "jest-diff": "^26.0.0", @@ -16438,9 +16438,9 @@ } }, "@types/jest": { - "version": "26.0.22", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.22.tgz", - "integrity": "sha512-eeWwWjlqxvBxc4oQdkueW5OF/gtfSceKk4OnOAGlUSwS/liBRtZppbJuz1YkgbrbfGOoeBHun9fOvXnjNwrSOw==", + "version": "26.0.23", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.23.tgz", + "integrity": "sha512-ZHLmWMJ9jJ9PTiT58juykZpL7KjwJywFN3Rr2pTSkyQfydf/rk22yS7W8p5DaVUMQ2BQC7oYiU3FjbTM/mYrOA==", "dev": true, "requires": { "jest-diff": "^26.0.0", diff --git a/ui/package.json b/ui/package.json index c643bbeb..7e0bca48 100644 --- a/ui/package.json +++ b/ui/package.json @@ -69,7 +69,7 @@ "@testing-library/jest-dom": "^5.11.10", "@testing-library/react": "^11.2.6", "@types/geojson": "^7946.0.7", - "@types/jest": "^26.0.22", + "@types/jest": "^26.0.23", "@types/mapbox-gl": "^2.1.1", "@types/react": "^17.0.3", "@types/react-dom": "^17.0.3", @@ -97,6 +97,10 @@ "arrowParens": "avoid" }, "jest": { + "testMatch": [ + "**/__tests__/**/*.+(ts|tsx|js)", + "**/?(*.)+(spec|test).+(ts|tsx|js)" + ], "transformIgnorePatterns": [ "^.+\\.css$" ], diff --git a/ui/src/components/albumGallery/AlbumGallery.tsx b/ui/src/components/albumGallery/AlbumGallery.tsx index 365cea2a..c916ba16 100644 --- a/ui/src/components/albumGallery/AlbumGallery.tsx +++ b/ui/src/components/albumGallery/AlbumGallery.tsx @@ -1,10 +1,11 @@ -import React, { useState, useEffect } from 'react' +import React, { useReducer } from 'react' import AlbumTitle from '../AlbumTitle' import PhotoGallery from '../photoGallery/PhotoGallery' import AlbumBoxes from './AlbumBoxes' import AlbumFilter from '../AlbumFilter' import { albumQuery_album } from '../../Pages/AlbumPage/__generated__/albumQuery' import { OrderDirection } from '../../../__generated__/globalTypes' +import { photoGalleryReducer } from '../photoGallery/photoGalleryReducer' type AlbumGalleryProps = { album?: albumQuery_album @@ -29,71 +30,49 @@ const AlbumGallery = React.forwardRef( setOrdering, ordering, onlyFavorites = false, - onFavorite, }: AlbumGalleryProps, ref: React.ForwardedRef ) => { - type ImageStateType = { - activeImage: number - presenting: boolean - } + // const [imageState, setImageState] = useState({ + // activeImage: -1, + // presenting: false, + // }) - const [imageState, setImageState] = useState({ - activeImage: -1, + const [mediaState, dispatchMedia] = useReducer(photoGalleryReducer, { presenting: false, + activeIndex: -1, + media: album?.media || [], }) - const setPresenting = (presenting: boolean) => - setImageState(state => ({ ...state, presenting })) + // const setPresentingWithHistory = (presenting: boolean) => { + // setPresenting(presenting) + // if (presenting) { + // history.pushState({ imageState }, '') + // } else { + // history.back() + // } + // } - const setPresentingWithHistory = (presenting: boolean) => { - setPresenting(presenting) - if (presenting) { - history.pushState({ imageState }, '') - } else { - history.back() - } - } + // const updateHistory = (imageState: ImageStateType) => { + // history.replaceState({ imageState }, '') + // return imageState + // } - const updateHistory = (imageState: ImageStateType) => { - history.replaceState({ imageState }, '') - return imageState - } + // useEffect(() => { + // const updateImageState = (event: PopStateEvent) => { + // setImageState(event.state.imageState) + // } - const setActiveImage = (activeImage: number) => { - setImageState(state => updateHistory({ ...state, activeImage })) - } + // window.addEventListener('popstate', updateImageState) - const nextImage = () => { - if (album === undefined) return - setActiveImage((imageState.activeImage + 1) % album.media.length) - } + // return () => { + // window.removeEventListener('popstate', updateImageState) + // } + // }, [imageState]) - const previousImage = () => { - if (album === undefined) return - - if (imageState.activeImage <= 0) { - setActiveImage(album.media.length - 1) - } else { - setActiveImage(imageState.activeImage - 1) - } - } - - useEffect(() => { - const updateImageState = (event: PopStateEvent) => { - setImageState(event.state.imageState) - } - - window.addEventListener('popstate', updateImageState) - - return () => { - window.removeEventListener('popstate', updateImageState) - } - }, [imageState]) - - useEffect(() => { - setActiveImage(-1) - }, [album]) + // useEffect(() => { + // setActiveImage(-1) + // }, [album]) let subAlbumElement = null @@ -135,16 +114,8 @@ const AlbumGallery = React.forwardRef( } { - setActiveImage(index) - }} - onFavorite={onFavorite} - setPresenting={setPresentingWithHistory} - nextImage={nextImage} - previousImage={previousImage} + mediaState={mediaState} + dispatchMedia={dispatchMedia} /> ) diff --git a/ui/src/components/photoGallery/MediaThumbnail.tsx b/ui/src/components/photoGallery/MediaThumbnail.tsx index 2ddb98c8..45c20836 100644 --- a/ui/src/components/photoGallery/MediaThumbnail.tsx +++ b/ui/src/components/photoGallery/MediaThumbnail.tsx @@ -1,22 +1,8 @@ import React, { useCallback, useState } from 'react' -import { useMutation, gql } from '@apollo/client' import styled from 'styled-components' import { Icon } from 'semantic-ui-react' import { ProtectedImage } from './ProtectedMedia' import { MediaType } from '../../../__generated__/globalTypes' -import { - markMediaFavorite, - markMediaFavoriteVariables, -} from './__generated__/markMediaFavorite' - -const markFavoriteMutation = gql` - mutation markMediaFavorite($mediaId: ID!, $favorite: Boolean!) { - favoriteMedia(mediaId: $mediaId, favorite: $favorite) { - id - favorite - } - } -` const MediaContainer = styled.div` flex-grow: 1; @@ -119,26 +105,19 @@ type MediaThumbnailProps = { height: number } } - onSelectImage(index: number): void - index: number active: boolean - setPresenting(presenting: boolean): void - onFavorite?(): void + selectImage(): void + clickPresent(): void + clickFavorite(): void } export const MediaThumbnail = ({ media, - onSelectImage, - index, active, - setPresenting, - onFavorite, + selectImage, + clickPresent, + clickFavorite, }: MediaThumbnailProps) => { - const [markFavorite] = useMutation< - markMediaFavorite, - markMediaFavoriteVariables - >(markFavoriteMutation) - let heartIcon = null if (media.favorite !== undefined) { heartIcon = ( @@ -147,21 +126,7 @@ export const MediaThumbnail = ({ name={media.favorite ? 'heart' : 'heart outline'} onClick={(event: MouseEvent) => { event.stopPropagation() - const favorite = !media.favorite - markFavorite({ - variables: { - mediaId: media.id, - favorite: favorite, - }, - optimisticResponse: { - favoriteMedia: { - id: media.id, - favorite: favorite, - __typename: 'Media', - }, - }, - }) - onFavorite && onFavorite() + clickFavorite() }} /> ) @@ -187,7 +152,7 @@ export const MediaThumbnail = ({ minWidth: `clamp(124px, ${minWidth}px, 100% - 8px)`, }} onClick={() => { - onSelectImage(index) + selectImage() }} >
{ - setPresenting(true) + clickPresent() }} /> {heartIcon} diff --git a/ui/src/components/photoGallery/PhotoGallery.tsx b/ui/src/components/photoGallery/PhotoGallery.tsx index dc02e942..6773c5ab 100644 --- a/ui/src/components/photoGallery/PhotoGallery.tsx +++ b/ui/src/components/photoGallery/PhotoGallery.tsx @@ -4,10 +4,28 @@ import { Loader } from 'semantic-ui-react' import { MediaThumbnail, PhotoThumbnail } from './MediaThumbnail' import PresentView from './presentView/PresentView' import { SidebarContext } from '../sidebar/Sidebar' -import MediaSidebar from '../sidebar/MediaSidebar' import { useTranslation } from 'react-i18next' import { PresentMediaProps_Media } from './presentView/PresentMedia' import { sidebarPhoto_media_thumbnail } from '../sidebar/__generated__/sidebarPhoto' +import { + PhotoGalleryAction, + PhotoGalleryState, + selectImageAction, +} from './photoGalleryReducer' +import { gql, useMutation } from '@apollo/client' +import { + markMediaFavorite, + markMediaFavoriteVariables, +} from './__generated__/markMediaFavorite' + +const markFavoriteMutation = gql` + mutation markMediaFavorite($mediaId: ID!, $favorite: Boolean!) { + favoriteMedia(mediaId: $mediaId, favorite: $favorite) { + id + favorite + } + } +` const Gallery = styled.div` display: flex; @@ -32,37 +50,32 @@ const ClearWrap = styled.div` clear: both; ` -interface PhotoGalleryProps_Media extends PresentMediaProps_Media { +export interface PhotoGalleryProps_Media extends PresentMediaProps_Media { thumbnail: sidebarPhoto_media_thumbnail | null + favorite?: boolean } type PhotoGalleryProps = { loading: boolean - media: PhotoGalleryProps_Media[] - activeIndex: number - presenting: boolean - onSelectImage(index: number): void - setPresenting(presenting: boolean): void - nextImage(): void - previousImage(): void - onFavorite?(): void + mediaState: PhotoGalleryState + dispatchMedia: React.Dispatch } const PhotoGallery = ({ - activeIndex = -1, - media, + mediaState, loading, - onSelectImage, - presenting, - setPresenting, - nextImage, - previousImage, - onFavorite, + dispatchMedia, }: PhotoGalleryProps) => { const { t } = useTranslation() + const { updateSidebar } = useContext(SidebarContext) - const activeImage: PhotoGalleryProps_Media | undefined = media[activeIndex] + const [markFavorite] = useMutation< + markMediaFavorite, + markMediaFavoriteVariables + >(markFavoriteMutation) + + const { media, activeIndex, presenting } = mediaState let photoElements = [] if (media) { @@ -73,14 +86,36 @@ const PhotoGallery = ({ { - updateSidebar() - onSelectImage(index) - }} - onFavorite={onFavorite} - setPresenting={setPresenting} - index={index} active={active} + selectImage={() => { + selectImageAction({ + index, + mediaState, + dispatchMedia, + updateSidebar, + }) + }} + clickFavorite={() => { + markFavorite({ + variables: { + mediaId: media.id, + favorite: !media.favorite, + }, + optimisticResponse: { + favoriteMedia: { + id: media.id, + favorite: !media.favorite, + __typename: 'Media', + }, + }, + }) + }} + clickPresent={() => { + dispatchMedia({ + type: 'setPresenting', + presenting: true, + }) + }} /> ) }) @@ -100,10 +135,7 @@ const PhotoGallery = ({ {presenting && ( - + )} ) diff --git a/ui/src/components/photoGallery/photoGalleryReducer.test.ts b/ui/src/components/photoGallery/photoGalleryReducer.test.ts new file mode 100644 index 00000000..d82966e9 --- /dev/null +++ b/ui/src/components/photoGallery/photoGalleryReducer.test.ts @@ -0,0 +1,91 @@ +import { photoGalleryReducer, PhotoGalleryState } from './photoGalleryReducer' +import { MediaType } from '../../../__generated__/globalTypes' + +describe('photo gallery reducer', () => { + const defaultState: PhotoGalleryState = { + presenting: false, + activeIndex: 0, + media: [ + { + __typename: 'Media', + id: '1', + highRes: null, + thumbnail: null, + type: MediaType.Photo, + }, + { + __typename: 'Media', + id: '2', + highRes: null, + thumbnail: null, + type: MediaType.Photo, + }, + { + __typename: 'Media', + id: '3', + highRes: null, + thumbnail: null, + type: MediaType.Photo, + }, + ], + } + + test('next image', () => { + expect(photoGalleryReducer(defaultState, { type: 'nextImage' })).toEqual({ + ...defaultState, + activeIndex: 1, + }) + + expect( + photoGalleryReducer( + { ...defaultState, activeIndex: 2 }, + { type: 'nextImage' } + ) + ).toEqual({ + ...defaultState, + activeIndex: 0, + }) + }) + + test('previous image', () => { + expect( + photoGalleryReducer(defaultState, { type: 'previousImage' }) + ).toEqual({ + ...defaultState, + activeIndex: 2, + }) + + expect( + photoGalleryReducer( + { ...defaultState, activeIndex: 2 }, + { type: 'previousImage' } + ) + ).toEqual({ + ...defaultState, + activeIndex: 1, + }) + }) + + test('select image', () => { + expect( + photoGalleryReducer(defaultState, { type: 'selectImage', index: 1 }) + ).toEqual({ + ...defaultState, + activeIndex: 1, + }) + + expect( + photoGalleryReducer(defaultState, { type: 'selectImage', index: 100 }) + ).toEqual({ + ...defaultState, + activeIndex: 2, + }) + + expect( + photoGalleryReducer(defaultState, { type: 'selectImage', index: -5 }) + ).toEqual({ + ...defaultState, + activeIndex: 0, + }) + }) +}) diff --git a/ui/src/components/photoGallery/photoGalleryReducer.tsx b/ui/src/components/photoGallery/photoGalleryReducer.tsx new file mode 100644 index 00000000..22f393e3 --- /dev/null +++ b/ui/src/components/photoGallery/photoGalleryReducer.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { UpdateSidebarFn } from '../sidebar/Sidebar' +import { PhotoGalleryProps_Media } from './PhotoGallery' +import MediaSidebar from '../sidebar/MediaSidebar' + +export type PhotoGalleryState = { + presenting: boolean + activeIndex: number + media: PhotoGalleryProps_Media[] +} + +export type PhotoGalleryAction = + | { type: 'nextImage' } + | { type: 'previousImage' } + | { type: 'setPresenting'; presenting: boolean } + | { type: 'selectImage'; index: number } + +export function photoGalleryReducer( + state: PhotoGalleryState, + action: PhotoGalleryAction +): PhotoGalleryState { + switch (action.type) { + case 'nextImage': + return { + ...state, + activeIndex: (state.activeIndex + 1) % state.media.length, + } + case 'previousImage': + if (state.activeIndex <= 0) { + return { + ...state, + activeIndex: state.media.length - 1, + } + } else { + return { + ...state, + activeIndex: state.activeIndex - 1, + } + } + case 'setPresenting': + return { + ...state, + presenting: action.presenting, + } + case 'selectImage': + return { + ...state, + activeIndex: Math.max( + 0, + Math.min(state.media.length - 1, action.index) + ), + } + } +} + +export const selectImageAction = ({ + index, + mediaState, + dispatchMedia, + updateSidebar, +}: { + index: number + mediaState: PhotoGalleryState + dispatchMedia: React.Dispatch + updateSidebar: UpdateSidebarFn +}) => { + updateSidebar( + + ) + dispatchMedia({ + type: 'selectImage', + index, + }) +} diff --git a/ui/src/components/photoGallery/presentView/PresentNavigationOverlay.tsx b/ui/src/components/photoGallery/presentView/PresentNavigationOverlay.tsx index 8d99e052..ff6da1e2 100644 --- a/ui/src/components/photoGallery/presentView/PresentNavigationOverlay.tsx +++ b/ui/src/components/photoGallery/presentView/PresentNavigationOverlay.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect } from 'react' import styled from 'styled-components' import { debounce, DebouncedFn } from '../../../helpers/utils' +import { PhotoGalleryAction } from '../photoGalleryReducer' import ExitIcon from './icons/Exit' import NextIcon from './icons/Next' @@ -65,16 +66,12 @@ const NavigationButton = styled(OverlayButton)<{ float: 'left' | 'right' }>` type PresentNavigationOverlayProps = { children?: React.ReactChild - nextImage(): void - previousImage(): void - setPresenting(presenting: boolean): void + dispatchMedia: React.Dispatch } const PresentNavigationOverlay = ({ children, - nextImage, - previousImage, - setPresenting, + dispatchMedia, }: PresentNavigationOverlayProps) => { const [hide, setHide] = useState(true) const onMouseMove = useRef void>>(null) @@ -103,20 +100,22 @@ const PresentNavigationOverlay = ({ previousImage()} + onClick={() => dispatchMedia({ type: 'previousImage' })} > nextImage()} + onClick={() => dispatchMedia({ type: 'nextImage' })} > setPresenting(false)} + onClick={() => + dispatchMedia({ type: 'setPresenting', presenting: false }) + } > diff --git a/ui/src/components/photoGallery/presentView/PresentView.tsx b/ui/src/components/photoGallery/presentView/PresentView.tsx index c8fb3b4c..beb9a11f 100644 --- a/ui/src/components/photoGallery/presentView/PresentView.tsx +++ b/ui/src/components/photoGallery/presentView/PresentView.tsx @@ -1,7 +1,8 @@ import React, { useEffect } from 'react' import styled, { createGlobalStyle } from 'styled-components' import PresentNavigationOverlay from './PresentNavigationOverlay' -import PresentMedia, { PresentMediaProps_Media } from './PresentMedia' +import PresentMedia from './PresentMedia' +import { PhotoGalleryAction, PhotoGalleryState } from '../photoGalleryReducer' const StyledContainer = styled.div` position: fixed; @@ -21,37 +22,33 @@ const PreventScroll = createGlobalStyle` ` type PresentViewProps = { - media: PresentMediaProps_Media className?: string imageLoaded?(): void - nextImage(): void - previousImage(): void - setPresenting(presenting: boolean): void + mediaState: PhotoGalleryState + dispatchMedia: React.Dispatch } const PresentView = ({ className, - media, imageLoaded, - nextImage, - previousImage, - setPresenting, + mediaState, + dispatchMedia, }: PresentViewProps) => { useEffect(() => { const keyDownEvent = (e: KeyboardEvent) => { if (e.key == 'ArrowRight') { - nextImage() e.stopPropagation() + dispatchMedia({ type: 'nextImage' }) } if (e.key == 'ArrowLeft') { - previousImage() e.stopPropagation() + dispatchMedia({ type: 'previousImage' }) } if (e.key == 'Escape') { - setPresenting(false) e.stopPropagation() + dispatchMedia({ type: 'setPresenting', presenting: false }) } } @@ -65,10 +62,11 @@ const PresentView = ({ return ( - - + + ) diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 49652a22..fb059ea6 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -52,9 +52,10 @@ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ "typeRoots": [ + "node_modules/@types", "./src/@types/" ] /* List of folders to include type definitions from. */, - // "types": [], /* Type declaration files to be included in compilation. */ + "types": ["jest"], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */