Skip to content

Commit

Permalink
Add Save valid image action (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
Marcos Jordão authored Aug 24, 2022
1 parent ce9cc9e commit 2ec5238
Show file tree
Hide file tree
Showing 12 changed files with 309 additions and 66 deletions.
91 changes: 86 additions & 5 deletions src/components/Dashboard/Dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,22 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SnackbarProvider } from 'notistack'
import { MemoryRouter } from 'react-router-dom'
import * as ImageRepositoryService from '../../services/ImageRepositoryService'
import * as ImageStorageService from '../../services/ImageStorageService/ImageStorageService'
import { MarkImageInvalidResponse } from '../../services/ImagesService/api/MarkImageInvalidApi'
import { SkipImageResponse } from '../../services/ImagesService/api/SkipImageApi'
import { SaveValidImageResponse } from '../../services/ImagesService/api/SaveValidImageApi'
import * as ImagesService from '../../services/ImagesService/ImagesService'
import { assertActionToolbarPresent } from './ActionToolbar/ActionToolbar.test.assertion'
import { Dashboard } from './Dashboard'
import { assertCanvasPresent, assertProgressBarPresent } from './Dashboard.test.assertion'
import { assertImageToolbarPresent } from './ImageToolbar/ImageToolbar.test.assertion'
import { assertNoPendingImagePresent } from './NoPendingImage/NoPendingImage.test.assertion'
import Image from '../../model/image'

describe('Dashboard', () => {
const renderWithExistingImage = (imageId = 'image-1') => {
jest.spyOn(ImagesService, 'fetchImageToLabel').mockResolvedValue({ id: imageId })
jest.spyOn(ImageRepositoryService, 'getImageUrl').mockResolvedValue(`http://image-url/${imageId}`)
jest.spyOn(ImagesService, 'fetchImageToLabel').mockResolvedValue({ id: imageId } as Image)
jest.spyOn(ImageStorageService, 'getImageUrl').mockResolvedValue(`http://image-url/${imageId}`)

const locationState = { userUid: 'user-1' }
return render(
Expand Down Expand Up @@ -146,7 +148,7 @@ describe('Dashboard', () => {
})

it('should fetch the next image to label when skip image succeed', async () => {
const fetchSpy = jest.spyOn(ImagesService, 'fetchImageToLabel').mockResolvedValue({ id: 'image-1' })
const fetchSpy = jest.spyOn(ImagesService, 'fetchImageToLabel').mockResolvedValue({ id: 'image-1' } as Image)
jest.spyOn(ImagesService, 'skipImage').mockResolvedValue({} as SkipImageResponse)

renderWithExistingImage()
Expand Down Expand Up @@ -227,7 +229,7 @@ describe('Dashboard', () => {
})

it('should fetch the next image to label when markImageInvalid succeed', async () => {
const fetchSpy = jest.spyOn(ImagesService, 'fetchImageToLabel').mockResolvedValue({ id: 'image-1' })
const fetchSpy = jest.spyOn(ImagesService, 'fetchImageToLabel').mockResolvedValue({ id: 'image-1' } as Image)
jest.spyOn(ImagesService, 'markImageInvalid').mockResolvedValue({} as MarkImageInvalidResponse)

renderWithExistingImage()
Expand All @@ -240,5 +242,84 @@ describe('Dashboard', () => {
})
})
})

describe('SaveValidImage', () => {
it('should call the saveValidImage function when clicking the Save button', async () => {
const saveImageSpy = jest.spyOn(ImagesService, 'saveValidImage').mockResolvedValue({} as SaveValidImageResponse)

renderWithExistingImage()

const saveButton = await screen.findByRole('button', { name: 'Save' })
userEvent.click(saveButton)

await waitFor(() => {
expect(saveImageSpy).toHaveBeenCalled()
})
})

it('should show a success message when Save image succeed', async () => {
jest.spyOn(ImagesService, 'saveValidImage').mockResolvedValue({} as SaveValidImageResponse)

renderWithExistingImage()

const saveButton = await screen.findByRole('button', { name: 'Save' })
userEvent.click(saveButton)

expect(await screen.findByText('Image saved with success.')).toBeInTheDocument()
})

it('should show an error message when Save image fails', async () => {
jest.spyOn(ImagesService, 'saveValidImage').mockRejectedValue('Error saving the image.')

renderWithExistingImage()

const saveButton = await screen.findByRole('button', { name: 'Save' })
userEvent.click(saveButton)

expect(await screen.findByText('Error saving the image.')).toBeInTheDocument()
})

it('should disable the buttons while saving the image', async () => {
renderWithExistingImage()

const saveButton = await screen.findByRole('button', { name: 'Save' })
const skipButton = await screen.findByRole('button', { name: 'Skip' })
const invalidButton = await screen.findByRole('button', { name: 'Invalid' })

// Check that the buttons are ENABLED
expect(saveButton).toBeEnabled()
expect(skipButton).toBeEnabled()
expect(invalidButton).toBeEnabled()

// Start saving the image
fireEvent.click(saveButton)

// Check that the buttons are DISABLED
expect(saveButton).toBeDisabled()
expect(skipButton).toBeDisabled()
expect(invalidButton).toBeDisabled()

// Wait for the save image to complete and check that the buttons are ENABLED again
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
})
expect(screen.getByRole('button', { name: 'Skip' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Invalid' })).toBeEnabled()
})

it('should fetch the next image to label when save image succeed', async () => {
const fetchSpy = jest.spyOn(ImagesService, 'fetchImageToLabel').mockResolvedValue({ id: 'image-1' } as Image)
jest.spyOn(ImagesService, 'saveValidImage').mockResolvedValue({} as SaveValidImageResponse)

renderWithExistingImage()

const saveButton = await screen.findByRole('button', { name: 'Save' })
userEvent.click(saveButton)

await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
})
})
})
})
41 changes: 26 additions & 15 deletions src/components/Dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ import styles from './Dashboard.module.css'
import CanvasDraw from 'react-canvas-draw'
import { ActionToolbar } from './ActionToolbar/ActionToolbar'
import { ImageToolbar } from './ImageToolbar/ImageToolbar'
import { getImageUrl } from '../../services/ImageRepositoryService'
import { fetchImageToLabel, markImageInvalid, skipImage } from '../../services/ImagesService/ImagesService'
import { getImageUrl } from '../../services/ImageStorageService/ImageStorageService'
import {
fetchImageToLabel,
markImageInvalid,
saveValidImage,
skipImage,
} from '../../services/ImagesService/ImagesService'
import { useLocation } from 'react-router-dom'
import Image from '../../model/image'
import useNotification from '../../services/Notification/NotificationService'
Expand Down Expand Up @@ -82,18 +87,7 @@ export const Dashboard = (): ReactElement => {
}
}

//TODO: Canvas is working fine but zoom in and zoom out miss don't keep image at center, workable but not great UX.
const saveAction = () => {
// if (canvas && sample) {
// const dataUri = canvas.getDataURL('png', false)
// await uploadImage(dataUri, sample.location, sample?.imageId, sample.maskId)
// getNewImage()
// clearAction()
// }
console.log('save action')
}

const undoAction = () => {
const undoCanvasAction = () => {
if (canvas) {
canvas.undo()
}
Expand All @@ -105,6 +99,23 @@ export const Dashboard = (): ReactElement => {
}
}

const saveAction = async () => {
try {
if (imageState && canvas) {
setIsLoading(true)

const maskImageData = canvas.getDataURL('png', false)
await saveValidImage(imageState, maskImageData)

showSuccessMessage('Image saved with success.')
await fetchImage()
}
} catch (error) {
showErrorMessage('Error saving the image.')
}
setIsLoading(false)
}

const skipAction = async () => {
try {
if (imageState?.id) {
Expand Down Expand Up @@ -145,7 +156,7 @@ export const Dashboard = (): ReactElement => {
{isImageLoaded && (
<>
<div className={styles.canvasContainer}>
<ActionToolbar clearAction={clearCanvas} undoAction={undoAction} />
<ActionToolbar clearAction={clearCanvas} undoAction={undoCanvasAction} />
<CanvasDraw
lazyRadius={0}
ref={(canvasDraw) => (canvas = canvasDraw)}
Expand Down
6 changes: 3 additions & 3 deletions src/model/image.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export default interface Image {
id?: string
name?: string
sampleLocation?: string
id: string
name: string
sampleLocation: string
sampleReference?: string
masks?: Mask[]
labellers?: string[]
Expand Down
40 changes: 0 additions & 40 deletions src/services/ImageRepositoryService.ts

This file was deleted.

48 changes: 48 additions & 0 deletions src/services/ImageStorageService/ImageStorageService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { getDownloadURL, getStorage, ref, StorageReference, uploadString } from 'firebase/storage'
import { ImageDimensions } from 'react-canvas-draw'
import Image from '../../model/image'
import { getImageIndexFromName } from '../../util/image-util'
import { MaskUploadResult } from './api/MaskUploadResult'

export async function getImageUrl(image: Image): Promise<string> {
const storage = getStorage()
const reference: StorageReference = ref(storage, image.sampleLocation + '/' + image.name)
return getDownloadURL(reference)
}

export async function uploadMaskImage(image: Image, maskImageFileContent: string): Promise<MaskUploadResult> {
const storage = getStorage()

const imageIndex = getImageIndexFromName(image.name)
const maskIndex = image.masks?.length ?? 0
const fileName = `mask_${imageIndex}_${maskIndex}.png`

const referencePath = `${image.sampleLocation}/${fileName}`
const reference = ref(storage, referencePath)
const uploadResult = await uploadString(reference, maskImageFileContent, 'data_url')

const maskUploadResult: MaskUploadResult = {
fileName: uploadResult.ref.name,
fullPath: uploadResult.ref.fullPath,
}

return maskUploadResult
}

export async function getImageDimensions(url: string): Promise<ImageDimensions> {
const metadata = await getMeta(url)

return {
width: metadata.width,
height: metadata.height,
}
}

function getMeta(url: string): Promise<HTMLImageElement> {
return new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => reject()
img.src = url
})
}
4 changes: 4 additions & 0 deletions src/services/ImageStorageService/api/MaskUploadResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface MaskUploadResult {
fileName: string
fullPath: string
}
75 changes: 72 additions & 3 deletions src/services/ImagesService/ImagesService.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,79 @@
/** @jest-environment node */
import * as functions from 'firebase/functions'
import Image from '../../model/image'
import { fetchImageToLabel, markImageInvalid, skipImage } from './ImagesService'
import { MarkImageInvalidResponse } from './api/MarkImageInvalidApi'
import { SaveValidImageResponse } from './api/SaveValidImageApi'
import { SkipImageResponse } from './api/SkipImageApi'
import { fetchImageToLabel, markImageInvalid, saveValidImage, skipImage } from './ImagesService'
import * as ImageStorageService from '../ImageStorageService/ImageStorageService'
import { MaskUploadResult } from '../ImageStorageService/api/MaskUploadResult'
jest.mock('firebase/functions')

describe('ImagesService', () => {
describe('saveValidImage', () => {
it('should save a valid image', async () => {
// Given a successful image mask upload
const imageId = 'image-1'
const maskName = `mask_${imageId}_0.png`
const imageStorageSpy = jest
.spyOn(ImageStorageService, 'uploadMaskImage')
.mockResolvedValue({ fileName: maskName, fullPath: `sample_location/${maskName}` } as MaskUploadResult)

// And CloudFunction call
const mockResponse: SaveValidImageResponse = {
message: 'Image saved`',
imageId: imageId,
labellerId: 'labeller-1',
}
const saveValidImageFunctionSpy = jest.fn(() => Promise.resolve({ data: mockResponse }))
jest.spyOn(functions, 'httpsCallable').mockReturnValue(saveValidImageFunctionSpy)

// When calling saveValidImage
const image: Image = {
id: imageId,
name: 'image_1.jpg',
sampleLocation: '095a46-sample-location',
}
const canvasMaskDataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA+...'

const result = await saveValidImage(image, canvasMaskDataUrl)

// Then the uploadMaskImage and the CloudFunction should have been called
expect(result).toEqual(mockResponse)
expect(imageStorageSpy).toHaveBeenCalled()
expect(saveValidImageFunctionSpy).toHaveBeenCalledWith({ imageId, maskName })
})

it('should fail and not call the saveValidImage cloud function if the mask upload fails', async () => {
// Given a failed mask image upload
const error = new Error('Upload Image error')
jest.spyOn(ImageStorageService, 'uploadMaskImage').mockRejectedValue(error)

const saveValidImageFunctionSpy = jest.fn()
jest.spyOn(functions, 'httpsCallable').mockReturnValue(saveValidImageFunctionSpy)

// When calling saveValidImage
const image: Image = {
id: 'image-1',
name: 'image_1.jpg',
sampleLocation: '095a46-sample-location',
}
const canvasMaskDataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA+...'

// Then an error is returned
await expect(async () => {
await saveValidImage(image, canvasMaskDataUrl)
}).rejects.toMatchObject(error)

// And the CloudFunction should not have been called
expect(saveValidImageFunctionSpy).not.toHaveBeenCalled()
})
})

describe('skipImage', () => {
it('should call the skipImage cloud function with the imageId', async () => {
const imageId = 'image-1'
const functionResponse = { message: 'Image skipped', imageId, labellerId: 'labeller-1' }
const functionResponse: SkipImageResponse = { message: 'Image skipped', imageId, labellerId: 'labeller-1' }

const functionsSpy = jest.spyOn(functions, 'httpsCallable')
const skipImageFunctionSpy = jest.fn(() => Promise.resolve({ data: functionResponse }))
Expand All @@ -25,7 +90,11 @@ describe('ImagesService', () => {
describe('markImageInvalid', () => {
it('should call the markImageInvalid cloud function with the imageId', async () => {
const imageId = 'image-1'
const functionResponse = { message: 'Image marked as invalid', imageId, labellerId: 'labeller-1' }
const functionResponse: MarkImageInvalidResponse = {
message: 'Image marked as invalid',
imageId,
labellerId: 'labeller-1',
}

const functionsSpy = jest.spyOn(functions, 'httpsCallable')
const markImageInvalidFunctionSpy = jest.fn(() => Promise.resolve({ data: functionResponse }))
Expand Down
Loading

0 comments on commit 2ec5238

Please sign in to comment.