1
Fork 0

Rewrite gallery to use reducer

This commit is contained in:
viktorstrate 2021-04-29 23:59:28 +02:00
parent 6693cb589c
commit a069eff0b2
No known key found for this signature in database
GPG Key ID: 3F855605109C1E8A
12 changed files with 309 additions and 174 deletions

2
.gitignore vendored
View File

@ -16,7 +16,7 @@ docker-compose.yml
node_modules/
# testing
/coverage
/ui/coverage
# building
.cache/

View File

@ -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(

14
ui/package-lock.json generated
View File

@ -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",

View File

@ -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$"
],

View File

@ -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<HTMLDivElement>
) => {
type ImageStateType = {
activeImage: number
presenting: boolean
}
// const [imageState, setImageState] = useState<ImageStateType>({
// activeImage: -1,
// presenting: false,
// })
const [imageState, setImageState] = useState<ImageStateType>({
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(
}
<PhotoGallery
loading={loading}
media={album?.media || []}
activeIndex={imageState.activeImage}
presenting={imageState.presenting}
onSelectImage={index => {
setActiveImage(index)
}}
onFavorite={onFavorite}
setPresenting={setPresentingWithHistory}
nextImage={nextImage}
previousImage={previousImage}
mediaState={mediaState}
dispatchMedia={dispatchMedia}
/>
</div>
)

View File

@ -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()
}}
>
<div
@ -203,7 +168,7 @@ export const MediaThumbnail = ({
<HoverIcon
name="expand"
onClick={() => {
setPresenting(true)
clickPresent()
}}
/>
{heartIcon}

View File

@ -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<PhotoGalleryAction>
}
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 = ({
<MediaThumbnail
key={media.id}
media={media}
onSelectImage={index => {
updateSidebar(<MediaSidebar media={media} />)
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 = ({
<PhotoFiller />
</Gallery>
{presenting && (
<PresentView
media={activeImage}
{...{ nextImage, previousImage, setPresenting }}
/>
<PresentView mediaState={mediaState} dispatchMedia={dispatchMedia} />
)}
</ClearWrap>
)

View File

@ -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,
})
})
})

View File

@ -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<PhotoGalleryAction>
updateSidebar: UpdateSidebarFn
}) => {
updateSidebar(
<MediaSidebar media={mediaState.media[mediaState.activeIndex]} />
)
dispatchMedia({
type: 'selectImage',
index,
})
}

View File

@ -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<PhotoGalleryAction>
}
const PresentNavigationOverlay = ({
children,
nextImage,
previousImage,
setPresenting,
dispatchMedia,
}: PresentNavigationOverlayProps) => {
const [hide, setHide] = useState(true)
const onMouseMove = useRef<null | DebouncedFn<() => void>>(null)
@ -103,20 +100,22 @@ const PresentNavigationOverlay = ({
<NavigationButton
className={hide ? 'hide' : undefined}
float="left"
onClick={() => previousImage()}
onClick={() => dispatchMedia({ type: 'previousImage' })}
>
<PrevIcon />
</NavigationButton>
<NavigationButton
className={hide ? 'hide' : undefined}
float="right"
onClick={() => nextImage()}
onClick={() => dispatchMedia({ type: 'nextImage' })}
>
<NextIcon />
</NavigationButton>
<ExitButton
className={hide ? 'hide' : undefined}
onClick={() => setPresenting(false)}
onClick={() =>
dispatchMedia({ type: 'setPresenting', presenting: false })
}
>
<ExitIcon />
</ExitButton>

View File

@ -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<PhotoGalleryAction>
}
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 (
<StyledContainer {...className}>
<PreventScroll />
<PresentNavigationOverlay
{...{ nextImage, previousImage, setPresenting }}
>
<PresentMedia media={media} imageLoaded={imageLoaded} />
<PresentNavigationOverlay dispatchMedia={dispatchMedia}>
<PresentMedia
media={mediaState.media[mediaState.activeIndex]}
imageLoaded={imageLoaded}
/>
</PresentNavigationOverlay>
</StyledContainer>
)

View File

@ -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. */