From ff058b748786f1ef1e085305efc3ec9cde797ee6 Mon Sep 17 00:00:00 2001 From: Stepan Usatiuk Date: Mon, 2 May 2022 22:03:06 +0200 Subject: [PATCH] select and delete multiple photos --- frontend/src/Photos/Overview.scss | 43 +++++++++++++---- frontend/src/Photos/Overview.tsx | 64 ++++++++++++++++++++++++- frontend/src/Photos/PhotoCard.tsx | 18 +++---- frontend/src/redux/api/photos.ts | 10 ++-- frontend/src/redux/photos/actions.ts | 72 ++++++++++++++-------------- frontend/src/redux/photos/reducer.ts | 33 ++++++++----- frontend/src/redux/photos/sagas.ts | 25 +++++----- frontend/tsconfig.json | 2 +- src/routes/photos.ts | 52 ++++++++++++++++++-- src/tests/integration/photos.test.ts | 52 +++++++++++++++++++- 10 files changed, 277 insertions(+), 94 deletions(-) diff --git a/frontend/src/Photos/Overview.scss b/frontend/src/Photos/Overview.scss index 028ed24..e1dbdd1 100644 --- a/frontend/src/Photos/Overview.scss +++ b/frontend/src/Photos/Overview.scss @@ -4,7 +4,36 @@ --photoOverlayDrawerWidth: 5rem; } -#photoOverlayContainer { +.operationsOverlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: $pt-navbar-height; + transition: all 0.3s; + + &.bp4-ovarlay-open { + z-index: 99; + } + + &.bp4-overlay-enter { + opacity: 0; + } + + &.bp4-overlay-enter-active { + opacity: 1; + } + + &.bp4-overlay-exit { + opacity: 1; + } + + &.bp4-overlay-exit-active { + opacity: 0; + } +} + +.operationsOverlay #photoOverlayContainer { width: 100%; height: 100%; align-items: center; @@ -40,11 +69,9 @@ opacity: 1; } } - } } - #photoOverlayDrawer { position: absolute; top: 0; @@ -89,7 +116,6 @@ } } - .bp4-overlay-exit { opacity: 1; @@ -149,14 +175,11 @@ .month, .year { - h3, h2 { - margin-top: 1rem; margin-left: 0.25rem; } - } .list { @@ -203,9 +226,9 @@ } } } - } .bp4-dark { - #overview {} -} \ No newline at end of file + #overview { + } +} diff --git a/frontend/src/Photos/Overview.tsx b/frontend/src/Photos/Overview.tsx index 835c07e..2b9b115 100644 --- a/frontend/src/Photos/Overview.tsx +++ b/frontend/src/Photos/Overview.tsx @@ -3,22 +3,29 @@ import * as React from "react"; import { connect } from "react-redux"; import { Dispatch } from "redux"; import { IAppState } from "../redux/reducers"; -import { photosLoadStart } from "../redux/photos/actions"; +import { + photosDeleteCancel, + photosDeleteStart, + photosLoadStart, +} from "../redux/photos/actions"; import { IPhotoReqJSON } from "../../../src/entity/Photo"; import { LoadingStub } from "../LoadingStub"; import { PhotoCard } from "./PhotoCard"; import { + Alignment, Button, Classes, H1, H2, H3, + Navbar, Overlay, Spinner, } from "@blueprintjs/core"; import { UploadButton } from "./UploadButton"; import { Photo } from "./Photo"; import { getPhotoThumbPath } from "../redux/api/photos"; +import { showDeletionToast } from "../AppToaster"; export interface IOverviewComponentProps { photos: IPhotoReqJSON[]; @@ -27,8 +34,11 @@ export interface IOverviewComponentProps { overviewFetching: boolean; overviewFetchingError: string | null; overviewFetchingSpinner: boolean; + darkMode: boolean; fetchPhotos: () => void; + startDeletePhotos: (photos: IPhotoReqJSON[]) => void; + cancelDelete: (photos: IPhotoReqJSON[]) => void; } const PhotoCardM = React.memo(PhotoCard); @@ -183,6 +193,49 @@ export const OverviewComponent: React.FunctionComponent< + 0} + transitionDuration={300} + hasBackdrop={false} + > +
+ + + + + + + +
+
@@ -206,11 +259,18 @@ function mapStateToProps(state: IAppState) { overviewFetchingError: state.photos.overviewFetchingError, overviewFetchingSpinner: state.photos.overviewFetchingSpinner, triedLoading: state.photos.triedLoading, + darkMode: state.localSettings.darkMode, }; } function mapDispatchToProps(dispatch: Dispatch) { - return { fetchPhotos: () => dispatch(photosLoadStart()) }; + return { + fetchPhotos: () => dispatch(photosLoadStart()), + startDeletePhotos: (photos: IPhotoReqJSON[]) => + dispatch(photosDeleteStart(photos)), + cancelDelete: (photos: IPhotoReqJSON[]) => + dispatch(photosDeleteCancel(photos)), + }; } export const Overview = connect( diff --git a/frontend/src/Photos/PhotoCard.tsx b/frontend/src/Photos/PhotoCard.tsx index b9cbc33..4c8da6b 100644 --- a/frontend/src/Photos/PhotoCard.tsx +++ b/frontend/src/Photos/PhotoCard.tsx @@ -12,7 +12,7 @@ import { IPhotoReqJSON } from "../../../src/entity/Photo"; import { getPhotoImgPath, getPhotoThumbPath } from "../redux/api/photos"; import { showDeletionToast } from "../AppToaster"; import { Dispatch } from "redux"; -import { photoDeleteCancel, photoDeleteStart } from "../redux/photos/actions"; +import { photosDeleteCancel, photosDeleteStart } from "../redux/photos/actions"; import { connect } from "react-redux"; import { LoadingStub } from "../LoadingStub"; import { RouteComponentProps, withRouter } from "react-router"; @@ -23,8 +23,8 @@ export interface IPhotoCardComponentProps extends RouteComponentProps { selected: boolean; id: string; - deletePhoto: (photo: IPhotoReqJSON) => void; - cancelDelete: (photo: IPhotoReqJSON) => void; + deletePhoto: (photos: IPhotoReqJSON[]) => void; + cancelDelete: (photos: IPhotoReqJSON[]) => void; onClick: (e: React.MouseEvent) => void; } @@ -55,8 +55,8 @@ export class PhotoCardComponent extends React.PureComponent< } public handleDelete(): void { - showDeletionToast(() => this.props.cancelDelete(this.props.photo)); - this.props.deletePhoto(this.props.photo); + showDeletionToast(() => this.props.cancelDelete([this.props.photo])); + this.props.deletePhoto([this.props.photo]); } /* public handleEdit() { @@ -122,10 +122,10 @@ export class PhotoCardComponent extends React.PureComponent< function mapDispatchToProps(dispatch: Dispatch) { return { - deletePhoto: (photo: IPhotoReqJSON) => - dispatch(photoDeleteStart(photo)), - cancelDelete: (photo: IPhotoReqJSON) => - dispatch(photoDeleteCancel(photo)), + deletePhoto: (photos: IPhotoReqJSON[]) => + dispatch(photosDeleteStart(photos)), + cancelDelete: (photos: IPhotoReqJSON[]) => + dispatch(photosDeleteCancel(photos)), }; } diff --git a/frontend/src/redux/api/photos.ts b/frontend/src/redux/api/photos.ts index 98f8e26..5644acb 100644 --- a/frontend/src/redux/api/photos.ts +++ b/frontend/src/redux/api/photos.ts @@ -1,7 +1,7 @@ import { IPhotoReqJSON } from "../../../../src/entity/Photo"; import { - IPhotosByIDDeleteRespBody, IPhotosByIDGetRespBody, + IPhotosDeleteRespBody, IPhotosListRespBody, IPhotosNewRespBody, IPhotosUploadRespBody, @@ -49,8 +49,8 @@ export async function uploadPhoto( return fetchJSONAuth(`/photos/upload/${id}`, "POST", file); } -export async function deletePhoto( - photo: IPhotoReqJSON, -): Promise { - return fetchJSONAuth(`/photos/byID/${photo.id}`, "DELETE"); +export async function deletePhotos( + photos: IPhotoReqJSON[], +): Promise { + return fetchJSONAuth(`/photos/delete`, "POST", { photos }); } diff --git a/frontend/src/redux/photos/actions.ts b/frontend/src/redux/photos/actions.ts index a43b657..dbc64c9 100644 --- a/frontend/src/redux/photos/actions.ts +++ b/frontend/src/redux/photos/actions.ts @@ -23,10 +23,10 @@ export enum PhotoTypes { PHOTO_UPLOAD_SUCCESS = "PHOTO_UPLOAD_SUCCESS", PHOTO_UPLOAD_FAIL = "PHOTO_UPLOAD_FAIL", PHOTOS_START_FETCHING_SPINNER = "PHOTOS_START_FETCHING_SPINNER", - PHOTO_DELETE_START = "PHOTO_DELETE_START", - PHOTO_DELETE_SUCCESS = "PHOTO_DELETE_SUCCESS", - PHOTO_DELETE_FAIL = "PHOTO_DELETE_FAIL", - PHOTO_DELETE_CANCEL = "PHOTO_DELETE_CANCEL", + PHOTOS_DELETE_START = "PHOTOS_DELETE_START", + PHOTOS_DELETE_SUCCESS = "PHOTOS_DELETE_SUCCESS", + PHOTOS_DELETE_FAIL = "PHOTOS_DELETE_FAIL", + PHOTOS_DELETE_CANCEL = "PHOTOS_DELETE_CANCEL", } export interface IPhotosLoadStartAction extends Action { @@ -109,25 +109,25 @@ export interface IPhotoCreateFailAction extends Action { error: string; } -export interface IPhotoDeleteStartAction extends Action { - type: PhotoTypes.PHOTO_DELETE_START; - photo: IPhotoReqJSON; +export interface IPhotosDeleteStartAction extends Action { + type: PhotoTypes.PHOTOS_DELETE_START; + photos: IPhotoReqJSON[]; } -export interface IPhotoDeleteSuccessAction extends Action { - type: PhotoTypes.PHOTO_DELETE_SUCCESS; - photo: IPhotoReqJSON; +export interface IPhotosDeleteSuccessAction extends Action { + type: PhotoTypes.PHOTOS_DELETE_SUCCESS; + photos: IPhotoReqJSON[]; } -export interface IPhotoDeleteFailAction extends Action { - type: PhotoTypes.PHOTO_DELETE_FAIL; - photo: IPhotoReqJSON; +export interface IPhotosDeleteFailAction extends Action { + type: PhotoTypes.PHOTOS_DELETE_FAIL; + photos: IPhotoReqJSON[]; error?: string; } -export interface IPhotoDeleteCancelAction extends Action { - type: PhotoTypes.PHOTO_DELETE_CANCEL; - photo: IPhotoReqJSON; +export interface IPhotosDeleteCancelAction extends Action { + type: PhotoTypes.PHOTOS_DELETE_CANCEL; + photos: IPhotoReqJSON[]; } export interface IPhotosStartFetchingSpinner extends Action { @@ -220,27 +220,27 @@ export function photoLoadFail(id: number, error: string): IPhotoLoadFailAction { return { type: PhotoTypes.PHOTO_LOAD_FAIL, id, error }; } -export function photoDeleteStart( - photo: IPhotoReqJSON, -): IPhotoDeleteStartAction { - return { type: PhotoTypes.PHOTO_DELETE_START, photo }; +export function photosDeleteStart( + photos: IPhotoReqJSON[], +): IPhotosDeleteStartAction { + return { type: PhotoTypes.PHOTOS_DELETE_START, photos }; } -export function photoDeleteSuccess( - photo: IPhotoReqJSON, -): IPhotoDeleteSuccessAction { - return { type: PhotoTypes.PHOTO_DELETE_SUCCESS, photo }; +export function photosDeleteSuccess( + photos: IPhotoReqJSON[], +): IPhotosDeleteSuccessAction { + return { type: PhotoTypes.PHOTOS_DELETE_SUCCESS, photos }; } -export function photoDeleteFail( - photo: IPhotoReqJSON, +export function photosDeleteFail( + photos: IPhotoReqJSON[], error?: string, -): IPhotoDeleteFailAction { - return { type: PhotoTypes.PHOTO_DELETE_FAIL, photo, error }; +): IPhotosDeleteFailAction { + return { type: PhotoTypes.PHOTOS_DELETE_FAIL, photos, error }; } -export function photoDeleteCancel( - photo: IPhotoReqJSON, -): IPhotoDeleteCancelAction { - return { type: PhotoTypes.PHOTO_DELETE_CANCEL, photo }; +export function photosDeleteCancel( + photos: IPhotoReqJSON[], +): IPhotosDeleteCancelAction { + return { type: PhotoTypes.PHOTOS_DELETE_CANCEL, photos }; } export function photosStartFetchingSpinner(): IPhotosStartFetchingSpinner { @@ -257,10 +257,10 @@ export type PhotoAction = | IPhotoCreateSuccessAction | IPhotoUploadFailAction | IPhotoUploadSuccessAction - | IPhotoDeleteFailAction - | IPhotoDeleteStartAction - | IPhotoDeleteSuccessAction - | IPhotoDeleteCancelAction + | IPhotosDeleteFailAction + | IPhotosDeleteStartAction + | IPhotosDeleteSuccessAction + | IPhotosDeleteCancelAction | IPhotoLoadFailAction | IPhotoLoadStartAction | IPhotoLoadSuccessAction diff --git a/frontend/src/redux/photos/reducer.ts b/frontend/src/redux/photos/reducer.ts index 340fdf1..f9737aa 100644 --- a/frontend/src/redux/photos/reducer.ts +++ b/frontend/src/redux/photos/reducer.ts @@ -196,15 +196,18 @@ export const photosReducer: Reducer = ( photosUploading: state.photosUploading - 1, }; } - case PhotoTypes.PHOTO_DELETE_START: { + case PhotoTypes.PHOTOS_DELETE_START: { const photos = state.photos; - const delPhoto = photos.find((p) => p.id === action.photo.id); - if (delPhoto) { + const photoIds = action.photos.map((p) => p.id); + const delPhotos = photos.find((p) => photoIds.includes(p.id)); + if (delPhotos) { const photosCleaned = photos.filter( - (p) => p.id !== action.photo.id, + (p) => !photoIds.includes(p.id), ); const delCache = { ...state.deleteCache }; - delCache[delPhoto?.id] = delPhoto; + for (const photo of action.photos) { + delCache[photo.id] = photo; + } return { ...state, photos: sortPhotos(photosCleaned), @@ -214,21 +217,25 @@ export const photosReducer: Reducer = ( return state; } } - case PhotoTypes.PHOTO_DELETE_SUCCESS: { + case PhotoTypes.PHOTOS_DELETE_SUCCESS: { const delCache = { ...state.deleteCache }; - if (delCache[action.photo.id]) { - delete delCache[action.photo.id]; + for (const photo of action.photos) { + if (delCache[photo.id]) { + delete delCache[photo.id]; + } } return { ...state, deleteCache: delCache }; break; } - case PhotoTypes.PHOTO_DELETE_FAIL: - case PhotoTypes.PHOTO_DELETE_CANCEL: { + case PhotoTypes.PHOTOS_DELETE_FAIL: + case PhotoTypes.PHOTOS_DELETE_CANCEL: { const delCache = { ...state.deleteCache }; let photos: IPhotoReqJSON[] = [...state.photos]; - if (delCache[action.photo.id]) { - photos = sortPhotos([...photos, delCache[action.photo.id]]); - delete delCache[action.photo.id]; + for (const photo of action.photos) { + if (delCache[photo.id]) { + photos = sortPhotos([...photos, delCache[photo.id]]); + delete delCache[photo.id]; + } } return { ...state, deleteCache: delCache, photos }; break; diff --git a/frontend/src/redux/photos/sagas.ts b/frontend/src/redux/photos/sagas.ts index 7ea645e..4b632a8 100644 --- a/frontend/src/redux/photos/sagas.ts +++ b/frontend/src/redux/photos/sagas.ts @@ -14,21 +14,21 @@ import { import * as SparkMD5 from "spark-md5"; import { createPhoto, - deletePhoto, + deletePhotos, fetchPhoto, fetchPhotosList, uploadPhoto, } from "../../redux/api/photos"; import { - IPhotoDeleteStartAction, + IPhotosDeleteStartAction, IPhotoLoadStartAction, IPhotosUploadStartAction, photoCreateFail, photoCreateQueue, photoCreateStart, photoCreateSuccess, - photoDeleteFail, - photoDeleteSuccess, + photosDeleteFail, + photosDeleteSuccess, photoLoadFail, photoLoadSuccess, photosLoadFail, @@ -250,34 +250,35 @@ function* photosUpload(action: IPhotosUploadStartAction) { } } -function* photoDelete(action: IPhotoDeleteStartAction) { +function* photosDelete(action: IPhotosDeleteStartAction) { try { const { cancelled } = yield race({ timeout: delay(3000), - cancelled: take(PhotoTypes.PHOTO_DELETE_CANCEL), + //FIXME: what happens if we delete multiple photos and then cancel some of them? + cancelled: take(PhotoTypes.PHOTOS_DELETE_CANCEL), }); if (!cancelled) { const { response, timeout } = yield race({ - response: call(deletePhoto, action.photo), + response: call(deletePhotos, action.photos), timeout: delay(10000), }); if (timeout) { - yield put(photoDeleteFail(action.photo, "Timeout")); + yield put(photosDeleteFail(action.photos, "Timeout")); return; } if (response) { if (response.data == null) { - yield put(photoDeleteFail(action.photo, response.error)); + yield put(photosDeleteFail(action.photos, response.error)); } else { - yield put(photoDeleteSuccess(action.photo)); + yield put(photosDeleteSuccess(action.photos)); } } } } catch (e) { - yield put(photoDeleteFail(action.photo, "Internal error")); + yield put(photosDeleteFail(action.photos, "Internal error")); } } @@ -286,7 +287,7 @@ export function* photosSaga() { takeLatest(PhotoTypes.PHOTOS_LOAD_START, photosLoad), takeLatest(PhotoTypes.PHOTOS_UPLOAD_START, photosUpload), takeLatest(PhotoTypes.PHOTO_LOAD_START, photoLoad), - takeEvery(PhotoTypes.PHOTO_DELETE_START, photoDelete), + takeEvery(PhotoTypes.PHOTOS_DELETE_START, photosDelete), takeEvery(PhotoTypes.PHOTO_CREATE_QUEUE, photoCreate), takeEvery(PhotoTypes.PHOTO_CREATE_SUCCESS, photoCreate), takeEvery(PhotoTypes.PHOTO_CREATE_FAIL, photoCreate), diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index eaaba3f..2bcd6fc 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -9,7 +9,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "sourceMap": true, - "noImplicitAny": true, + "noImplicitAny": false, "allowSyntheticDefaultImports": true, "strictFunctionTypes": true, "strictNullChecks": true, diff --git a/src/routes/photos.ts b/src/routes/photos.ts index 11c1213..d861360 100644 --- a/src/routes/photos.ts +++ b/src/routes/photos.ts @@ -8,6 +8,7 @@ import { getHash, getSize } from "~util"; import * as jwt from "jsonwebtoken"; import { config } from "~config"; import { ValidationError } from "class-validator"; +import { In } from "typeorm"; export const photosRouter = new Router(); @@ -277,7 +278,10 @@ photosRouter.get("/photos/showByID/:id/:token", async (ctx) => { return; } - if (ctx.request.query["size"] && typeof ctx.request.query["size"] == "string") { + if ( + ctx.request.query["size"] && + typeof ctx.request.query["size"] == "string" + ) { const size = parseInt(ctx.request.query["size"]); await send(ctx, await photo.getReadyThumbnailPath(size)); return; @@ -309,7 +313,10 @@ photosRouter.get("/photos/showByID/:id", async (ctx) => { return; } - if (ctx.request.query["size"] && typeof ctx.request.query["size"] == "string") { + if ( + ctx.request.query["size"] && + typeof ctx.request.query["size"] == "string" + ) { const size = parseInt(ctx.request.query["size"]); await send(ctx, await photo.getReadyThumbnailPath(size)); return; @@ -347,7 +354,7 @@ photosRouter.get("/photos/getShowByIDToken/:id", async (ctx) => { ctx.body = { error: false, data: token } as IPhotosGetShowTokenByID; }); -export type IPhotosByIDDeleteRespBody = IAPIResponse; +export type IPhotoByIDDeleteRespBody = IAPIResponse; photosRouter.delete("/photos/byID/:id", async (ctx) => { if (!ctx.state.user) { ctx.throw(401); @@ -376,5 +383,42 @@ photosRouter.delete("/photos/byID/:id", async (ctx) => { ctx.body = { error: false, data: true, - } as IPhotosByIDDeleteRespBody; + } as IPhotoByIDDeleteRespBody; +}); + +export interface IPhotosDeleteBody { + photos: IPhotoReqJSON[]; +} + +export type IPhotosDeleteRespBody = IAPIResponse; +photosRouter.post("/photos/delete", async (ctx) => { + if (!ctx.state.user) { + ctx.throw(401); + } + + const body = ctx.request.body as IPhotosDeleteBody; + const { photos } = body; + + if (!photos || !Array.isArray(photos) || photos.length == 0) { + ctx.throw(400); + return; + } + + const { user } = ctx.state; + try { + await Photo.delete({ + id: In(photos.map((photo) => photo.id)), + user, + }); + + ctx.body = { + error: false, + data: true, + } as IPhotosDeleteRespBody; + } catch (e) { + ctx.body = { + data: null, + error: "Internal server error", + } as IPhotosDeleteRespBody; + } }); diff --git a/src/tests/integration/photos.test.ts b/src/tests/integration/photos.test.ts index b5f0407..268d765 100644 --- a/src/tests/integration/photos.test.ts +++ b/src/tests/integration/photos.test.ts @@ -4,7 +4,11 @@ import * as request from "supertest"; import { getConnection } from "typeorm"; import { app } from "~app"; import { Photo, IPhotoReqJSON } from "~entity/Photo"; -import { IPhotosListRespBody, IPhotosNewPostBody } from "~routes/photos"; +import { + IPhotosDeleteBody, + IPhotosListRespBody, + IPhotosNewPostBody, +} from "~routes/photos"; import * as fs from "fs/promises"; import { constants as fsConstants } from "fs"; import * as jwt from "jsonwebtoken"; @@ -618,10 +622,14 @@ describe("photos", function () { 512, ); const response = await request(callback) - .delete(`/photos/byID/${seed.dogPhoto.id}`) + .post(`/photos/delete`) .set({ Authorization: `Bearer ${seed.user2.toJWT()}`, + "Content-Type": "application/json", }) + .send({ + photos: [await seed.dogPhoto.toReqJSON()], + } as IPhotosDeleteBody) .expect(200); expect(response.body.error).to.be.false; @@ -636,4 +644,44 @@ describe("photos", function () { assert(true); } }); + + it("should delete two photos", async function () { + const photo1Path = seed.dogPhoto.getPath(); + const photo2Path = seed.catPhoto.getPath(); + const photo1SmallThumbPath = await seed.dogPhoto.getReadyThumbnailPath( + 512, + ); + const photo2SmallThumbPath = await seed.catPhoto.getReadyThumbnailPath( + 512, + ); + const response = await request(callback) + .post(`/photos/delete`) + .set({ + Authorization: `Bearer ${seed.user2.toJWT()}`, + "Content-Type": "application/json", + }) + .send({ + photos: [ + await seed.dogPhoto.toReqJSON(), + await seed.catPhoto.toReqJSON(), + ], + } as IPhotosDeleteBody) + .expect(200); + + expect(response.body.error).to.be.false; + const dbPhoto1 = await Photo.findOne(seed.dogPhoto.id); + expect(dbPhoto1).to.be.undefined; + const dbPhoto2 = await Photo.findOne(seed.catPhoto.id); + expect(dbPhoto2).to.be.undefined; + + try { + await fs.access(photo1Path, fsConstants.F_OK); + await fs.access(photo1SmallThumbPath, fsConstants.F_OK); + await fs.access(photo2Path, fsConstants.F_OK); + await fs.access(photo2SmallThumbPath, fsConstants.F_OK); + assert(false); + } catch (e) { + assert(true); + } + }); });