Collections: allow to drag and drop to change book position

This commit is contained in:
2026-04-12 01:44:25 +02:00
parent d2fe3bf34f
commit 178c688203
15 changed files with 328 additions and 12 deletions

View File

@@ -28,6 +28,7 @@ func CollectionItemsQueryToDto(itemsQueryResult []query.CollectionItemQueryResul
}
dtoItems = append(dtoItems,
dto.CollectionItemGet{
ID: queryResult.ItemID,
Position: queryResult.Position,
Book: bookItem,
})

View File

@@ -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(3), res.Count)
assert.Equal(t, 3, len(res.Collections))
assert.Equal(t, int64(4), res.Count)
assert.Equal(t, 4, len(res.Collections))
}
func testFetchCollections(t *testing.T, limit string, offset string) (int, dto.CollectionItemsGet) {

View File

@@ -0,0 +1,113 @@
package apitest
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestPostCollectionChangePositionHandler_PositionOk(t *testing.T) {
payload :=
`{
"itemId": 14,
"position": 2
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, collectionId, "10", "0")
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)
}
func TestPostCollectionChangePositionHandler_ChangeOtherElement(t *testing.T) {
payload :=
`{
"itemId": 17,
"position": 3
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, "Duo", collection.Items[3].Book.Title)
}
func TestPostCollectionChangePositionHandler_LastPosition(t *testing.T) {
payload :=
`{
"itemId": 19,
"position": 546
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, "Recherches philosophiques", collection.Items[7].Book.Title)
assert.Equal(t, "Le château", collection.Items[6].Book.Title)
}
func TestPostCollectionChangePositionHandler_FirstPosition(t *testing.T) {
payload :=
`{
"itemId": 16,
"position": 1
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, "Duo", collection.Items[0].Book.Title)
}
func TestPostCollectionChangePositionHandler_WrongPosition(t *testing.T) {
payload :=
`{
"itemId": 9,
"position": 0
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusBadRequest, status)
}
func TestPostCollectionChangePositionHandler_MissingPosition(t *testing.T) {
payload :=
`{
"itemId": 9
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusBadRequest, status)
}
func TestPostCollectionChangePositionWrongCollection(t *testing.T) {
payload :=
`{
"itemId": 1,
"position": 9
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusInternalServerError, status)
}
func testPostCollectionChangePositionHandler(collectionId string, payload string) int {
router := testutils.TestSetup()
w := httptest.NewRecorder()
token := testutils.ConnectDemoUser(router)
req, _ := http.NewRequest("POST", "/ws/collection/"+collectionId+"/changeposition",
strings.NewReader(payload))
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
router.ServeHTTP(w, req)
return w.Code
}

View File

@@ -51,6 +51,11 @@ type CollectionBook struct {
BookID uint `json:"bookId" binding:"required"`
}
type CollectionItemPosition struct {
Position uint `json:"position" binding:"required,gte=1"`
ItemID uint `json:"itemId" binding:"required"`
}
type FileInfoPost struct {
FileID uint `json:"fileId"`
FilePath string `json:"filepath"`

View File

@@ -51,6 +51,7 @@ type CollectionGet struct {
}
type CollectionItemGet struct {
ID uint `json:"id"`
Position uint `json:"position"`
Book BookItemGet `json:"book"`
}

View File

@@ -10,3 +10,4 @@ RegistrationDisabled = "Registration has been disabled on this instance."
UserAlreadyExists = "An user with this name already exists."
ErrorWhenCreatingUserFromStr = "Error when creating user from string %s"
Unauthorized = "You are not allowed to access this document."
ItemDoesNotBelongToCollection = "Item does not belong to the collection."

View File

@@ -10,3 +10,4 @@ RegistrationDisabled = "La création de nouveaux comptes a été désactivée su
UserAlreadyExists = "Un utilisateur avec le même nom existe déjà."
ErrorWhenCreatingUserFromStr = "Erreur lors de la création de l'utilisateur %s"
Unauthorized = "Vous n'êtes pas autorisé à accéder à cette page."
ItemDoesNotBelongToCollection = "Cet élément n'appartient pas à la liste."

View File

@@ -81,6 +81,7 @@ func fetchCollections(db *gorm.DB, userId uint) *gorm.DB {
}
type CollectionItemQueryResult struct {
ItemID uint
Position uint
ID uint
Title string
@@ -113,7 +114,7 @@ func FetchCollectionBooksCount(db *gorm.DB, userId uint, collectionId uint) (int
func fetchCollectionBooksQuery(db *gorm.DB, userId uint, collectionId uint) *gorm.DB {
query := db.Model(&model.CollectionItem{})
query = query.Select("collection_items.position, " + selectBookItem())
query = query.Select("collection_items.position, collection_items.ID as item_id, " + selectBookItem())
query = query.Joins("left join collections on (collection_items.collection_id = collections.id)")
query = query.Joins("left join books on (books.id = collection_items.book_id)")
query = joinAuthors(query)

View File

@@ -11,6 +11,7 @@ import (
"git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func PostCollectionBookHandler(ac appcontext.AppContext) {
@@ -56,7 +57,16 @@ func PostCollectionBookHandler(ac appcontext.AppContext) {
return
}
item := model.CollectionItem{Position: 0, BookID: book.ID, CollectionID: collection.ID}
//reorder other items
q := ac.Db.Model(&model.CollectionItem{})
q = q.Where("collection_id = ?", collection.ID)
err = q.UpdateColumn("position", gorm.Expr("position + 1")).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
item := model.CollectionItem{Position: 1, BookID: book.ID, CollectionID: collection.ID}
ac.Db.Save(&item)
ac.C.String(http.StatusOK, "Success")
}

View File

@@ -0,0 +1,106 @@
package routes
import (
"errors"
"net/http"
"strconv"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto"
"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 PostCollectionChangePositionHandler(ac appcontext.AppContext) {
collectionId, 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 collection model.Collection
err = ac.Db.First(&collection, collectionId).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
if collection.UserID != user.ID {
err := myvalidator.HttpError{
StatusCode: http.StatusUnauthorized,
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "Unauthorized")),
}
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var collectionBookPosition dto.CollectionItemPosition
err = ac.C.ShouldBindJSON(&collectionBookPosition)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var item model.CollectionItem
err = ac.Db.First(&item, collectionBookPosition.ItemID).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
if collection.ID != item.CollectionID {
err := myvalidator.HttpError{
StatusCode: http.StatusInternalServerError,
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "ItemDoesNotBelongToCollection")),
}
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
count, err := query.FetchCollectionBooksCount(ac.Db, user.ID, item.CollectionID)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
newPosition := collectionBookPosition.Position
if int64(newPosition) > count {
newPosition = uint(count)
}
if item.Position == collectionBookPosition.Position {
//nothing to do
ac.C.String(http.StatusOK, "Success")
return
}
lowerPosition := item.Position + 1
higherPosition := item.Position - 1
operationToDo := ""
if item.Position < collectionBookPosition.Position {
higherPosition = collectionBookPosition.Position
operationToDo = "position - 1"
} else {
lowerPosition = collectionBookPosition.Position
operationToDo = "position + 1"
}
q := ac.Db.Model(&model.CollectionItem{})
q = q.Where("collection_id = ? AND position BETWEEN ? AND ?", collection.ID, lowerPosition, higherPosition)
err = q.UpdateColumn("position", gorm.Expr(operationToDo)).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
item.Position = collectionBookPosition.Position
ac.Db.Save(&item)
ac.C.String(http.StatusOK, "Success")
}

View File

@@ -87,6 +87,9 @@ func Setup(config *config.Config) *gin.Engine {
ws.POST("/collection/:id/addbook", func(c *gin.Context) {
routes.PostCollectionBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.POST("/collection/:id/changeposition", func(c *gin.Context) {
routes.PostCollectionChangePositionHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.POST("/collection", func(c *gin.Context) {
routes.PostCollectionHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})