From 08a273b5005d583a100f3fe56ec53cacb6179089 Mon Sep 17 00:00:00 2001 From: Arthur Lefebvre Date: Fri, 24 Apr 2026 19:48:38 +0200 Subject: [PATCH] Collection form: add a button to remove added books --- demodata.sql | 8 +++ front/src/CollectionForm.vue | 21 ++++--- front/src/CollectionFormElement.vue | 12 +++- front/src/api.js | 20 ++++++ .../apitest/delete_collection_element_test.go | 45 ++++++++++++++ internal/apitest/fetchallcollections_test.go | 4 +- internal/query/querycollections.go | 10 +++ internal/routes/collectionremovebookdelete.go | 62 +++++++++++++++++++ internal/setup/setup.go | 4 +- 9 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 internal/apitest/delete_collection_element_test.go create mode 100644 internal/routes/collectionremovebookdelete.go diff --git a/demodata.sql b/demodata.sql index 3ea576a..dc3b6ae 100644 --- a/demodata.sql +++ b/demodata.sql @@ -132,6 +132,7 @@ INSERT INTO collections(name, user_id) VALUES ('Nouvelles',(SELECT id FROM users INSERT INTO collections(name, user_id) VALUES ('Non fiction',(SELECT id FROM users WHERE name = 'demo2')); INSERT INTO collections(name, user_id) VALUES ('Empty',(SELECT id FROM users WHERE name = 'demo')); INSERT INTO collections(name, user_id) VALUES ('Lu récemment',(SELECT id FROM users WHERE name = 'demo')); +INSERT INTO collections(name, user_id) VALUES ('Brouillon',(SELECT id FROM users WHERE name = 'demo')); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Nord'), 1); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Gargantua'), 2); @@ -157,3 +158,10 @@ INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT i INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Recherches philosophiques'), 6); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Dojoji et autres nouvelles'), 7); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Le château'), 8); + +INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Nord'), 1); +INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Gargantua'), 2); +INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Duo'), 3); +INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Un barrage contre le Pacifique'), 4); +INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Rigodon'), 5); +INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Les dieux ont soif'), 6); diff --git a/front/src/CollectionForm.vue b/front/src/CollectionForm.vue index 049f6f2..cd780ca 100644 --- a/front/src/CollectionForm.vue +++ b/front/src/CollectionForm.vue @@ -1,6 +1,6 @@ @@ -153,7 +156,6 @@ function onPointerMove(e) { color: var(--bulma-scheme-main); font-size: 48px; margin-left: 30px; - margin-right: 30px; border-top-right-radius: var(--bulma-box-radius); border-bottom-right-radius: var(--bulma-box-radius); cursor: grab; @@ -169,6 +171,12 @@ function onPointerMove(e) { border-radius: 10px; } +.closebtn { + height: 40px; + width: 40px; + font-size: 36px; +} + @media (max-width: 1024px) { .inputpositionwidget { margin-top: 10px; diff --git a/front/src/api.js b/front/src/api.js index aac0c21..ccab0f4 100644 --- a/front/src/api.js +++ b/front/src/api.js @@ -146,6 +146,10 @@ export function postCollectionChangePosition(collectionId, itemId, position) { ) } +export function deleteCollectionItem(itemId) { + return deleteApiCall('/ws/collection/item/' + itemId) +} + export function putBook(id, book) { return genericPayloadCall('/ws/book/edit/' + id, book.value, 'PUT') } @@ -234,6 +238,22 @@ export function genericPayloadCall(apiRoute, object, method) { } } +export function deleteApiCall(apiRoute) { + const { user } = useAuthStore() + + if (user != null) { + return fetch(apiRoute, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + user.token, + }, + }) + } else { + return Promise.resolve() + } +} + export function extractFormErrorFromField(fieldName, errors) { if (errors == null || typeof errors == 'undefined' || !Array.isArray(errors)) { return '' diff --git a/internal/apitest/delete_collection_element_test.go b/internal/apitest/delete_collection_element_test.go new file mode 100644 index 0000000..eae04ba --- /dev/null +++ b/internal/apitest/delete_collection_element_test.go @@ -0,0 +1,45 @@ +package apitest + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "git.artlef.fr/bibliomane/internal/testutils" + "github.com/stretchr/testify/assert" +) + +func TestDeleteCollectionBookHandler_DelOk(t *testing.T) { + status := testDeleteCollectionBookHandler("23") + assert.Equal(t, http.StatusOK, status) + _, collection := testGetCollection(t, "6", "10", "0") + assert.Equal(t, int64(5), collection.Count) + assert.Equal(t, uint(1), collection.Items[0].Position) + assert.Equal(t, uint(2), collection.Items[1].Position) + assert.Equal(t, uint(3), collection.Items[2].Position) + assert.Equal(t, uint(4), collection.Items[3].Position) + assert.Equal(t, uint(5), collection.Items[4].Position) +} + +func TestDeleteCollectionBookHandler_NotFound(t *testing.T) { + status := testDeleteCollectionBookHandler("425") + assert.Equal(t, http.StatusNotFound, status) +} + +func TestDeleteCollectionBookHandler_Unauthorized(t *testing.T) { + status := testDeleteCollectionBookHandler("11") + assert.Equal(t, http.StatusUnauthorized, status) +} + +func testDeleteCollectionBookHandler(itemId string) int { + router := testutils.TestSetup() + w := httptest.NewRecorder() + + token := testutils.ConnectDemoUser(router) + req, _ := http.NewRequest("DELETE", "/ws/collection/item/"+itemId, nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + router.ServeHTTP(w, req) + + return w.Code +} diff --git a/internal/apitest/fetchallcollections_test.go b/internal/apitest/fetchallcollections_test.go index 4eadc6d..eb1c76b 100644 --- a/internal/apitest/fetchallcollections_test.go +++ b/internal/apitest/fetchallcollections_test.go @@ -12,8 +12,8 @@ import ( func TestFetchAllCollections_OK(t *testing.T) { status, res := testFetchCollections(t, "10", "0") assert.Equal(t, http.StatusOK, status) - assert.Equal(t, int64(4), res.Count) - assert.Equal(t, 4, len(res.Collections)) + assert.Equal(t, int64(5), res.Count) + assert.Equal(t, 5, len(res.Collections)) } func testFetchCollections(t *testing.T, limit string, offset string) (int, dto.CollectionItemsGet) { diff --git a/internal/query/querycollections.go b/internal/query/querycollections.go index a19a365..2c58248 100644 --- a/internal/query/querycollections.go +++ b/internal/query/querycollections.go @@ -20,6 +20,16 @@ func FetchCollectionHeader(db *gorm.DB, collectionId uint) (CollectionHeader, er return collection, res.Error } +func FetchCollectionHeaderFromItem(db *gorm.DB, itemId uint) (CollectionHeader, error) { + var collection CollectionHeader + query := db.Model(&model.Collection{}) + query = query.Select("collections.name, collections.user_id") + query = query.Joins("left join collection_items on (collection_items.collection_id = collections.id)") + query = query.Where("collection_items.id = ?", itemId) + res := query.Find(&collection) + return collection, res.Error +} + type CollectionsQueryResult struct { ID uint UserID uint diff --git a/internal/routes/collectionremovebookdelete.go b/internal/routes/collectionremovebookdelete.go new file mode 100644 index 0000000..d84c271 --- /dev/null +++ b/internal/routes/collectionremovebookdelete.go @@ -0,0 +1,62 @@ +package routes + +import ( + "errors" + "net/http" + "strconv" + + "git.artlef.fr/bibliomane/internal/appcontext" + "git.artlef.fr/bibliomane/internal/i18nresource" + "git.artlef.fr/bibliomane/internal/model" + "git.artlef.fr/bibliomane/internal/myvalidator" + "git.artlef.fr/bibliomane/internal/query" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func DeleteCollectionBookHandler(ac appcontext.AppContext) { + collectionItemId, err := strconv.ParseUint(ac.C.Param("id"), 10, 64) + if err != nil { + ac.C.JSON(http.StatusBadRequest, gin.H{"error": err}) + return + } + + user, err := ac.GetAuthenticatedUser() + if err != nil { + myvalidator.ReturnErrorsAsJsonResponse(&ac, err) + return + } + + var collectionItem model.CollectionItem + err = ac.Db.First(&collectionItem, collectionItemId).Error + if err != nil { + myvalidator.ReturnErrorsAsJsonResponse(&ac, err) + return + } + collection, err := query.FetchCollectionHeaderFromItem(ac.Db, collectionItem.ID) + + if collection.UserID != user.ID { + err := myvalidator.HttpError{ + StatusCode: http.StatusUnauthorized, + Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "Unauthorized")), + } + myvalidator.ReturnErrorsAsJsonResponse(&ac, err) + return + } + + err = ac.Db.Delete(&collectionItem).Error + if err != nil { + myvalidator.ReturnErrorsAsJsonResponse(&ac, err) + return + } + + //update position on remaining items + q := ac.Db.Model(&model.CollectionItem{}) + q = q.Where("collection_id = ? AND position > ?", collectionItem.CollectionID, collectionItem.Position) + err = q.UpdateColumn("position", gorm.Expr("position - 1")).Error + if err != nil { + myvalidator.ReturnErrorsAsJsonResponse(&ac, err) + return + } + ac.C.JSON(http.StatusOK, "Success") +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go index e20cebc..ae0c5a0 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -102,7 +102,9 @@ func Setup(config *config.Config) *gin.Engine { ws.POST("/upload/cover", func(c *gin.Context) { routes.PostUploadBookCoverHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) }) - + ws.DELETE("/collection/item/:id", func(c *gin.Context) { + routes.DeleteCollectionBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) + }) r.Static("/static/bookcover", config.ImageFolderPath) folders := []string{"assets", "css", "image"}