Collection: new widget to add book to collection

This commit is contained in:
2026-04-04 23:15:44 +02:00
parent c7abbfe4d4
commit 2552ba8e94
11 changed files with 279 additions and 16 deletions

View File

@@ -0,0 +1,95 @@
<script setup>
import { ref, computed } from 'vue'
import { getSearchBooks, postCollectionAddBook, extractFormErrorFromField } from './api.js'
const props = defineProps({
collectionId: Number,
})
const emit = defineEmits(['created'])
const book = ref({
id: 0,
title: '',
})
const addingBook = ref(false)
const data = ref(null)
const error = ref(null)
const titleError = computed(() => {
return extractFormErrorFromField('Title', error.value)
})
const vFocus = {
mounted: (el) => el.focus(),
}
const limit = 5
function fetchBooks() {
if (!book || book.value.title.length < 3) {
return
}
const lang = navigator.language.substring(0, 2)
getSearchBooks(data, error, book.value.title, lang, 0, limit, 0)
}
function addBook(bookId) {
postCollectionAddBook(props.collectionId, bookId).then((res) => {
if (res.ok) {
addingBook.value = false
book.value.id = 0
book.value.title = ''
data.value = null
error.value = null
emit('created')
} else {
res.json().then((json) => {
console.log(json)
error.value = json
})
}
})
}
</script>
<template>
<div class="field has-addons">
<div v-if="addingBook" class="control">
<input
:class="'input is-large ' + (titleError ? 'is-danger' : '')"
v-focus
@keyup="fetchBooks()"
type="text"
maxlength="300"
v-model="book.title"
:placeholder="$t('addbook.title')"
/>
<p v-if="titleError" class="help is-danger">{{ titleError }}</p>
<ul v-if="data" class="popupresults has-background-dark">
<li v-for="book in data.books" @click="addBook(book.id)" class="bookresult p-2">
{{ book.title }}
</li>
</ul>
</div>
<div v-if="!addingBook" class="control">
<button @click="addingBook = true" class="button is-large mb-2">
<span class="icon" :title="$t('collections.add')">
<b-icon-plus />
</span>
</button>
</div>
</div>
</template>
<style scoped>
.popupresults {
z-index: 999;
}
.bookresult {
cursor: pointer;
}
.bookresult:hover {
background-color: var(--bulma-text-40);
}
</style>

View File

@@ -2,6 +2,7 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { getCollection } from './api.js' import { getCollection } from './api.js'
import CollectionFormBookItem from './CollectionFormBookItem.vue' import CollectionFormBookItem from './CollectionFormBookItem.vue'
import AddBookToCollection from './AddBookToCollection.vue'
import Pagination from './Pagination.vue' import Pagination from './Pagination.vue'
const props = defineProps({ const props = defineProps({
@@ -27,14 +28,20 @@ getCollection(data, error, props.id, limit, offset.value)
function pageChange(newPageNumber) { function pageChange(newPageNumber) {
pageNumber.value = newPageNumber pageNumber.value = newPageNumber
data.value = null data.value = null
error.value = null
getCollection(data, error, props.id, limit, offset.value) getCollection(data, error, props.id, limit, offset.value)
} }
function fetchCollection() {
pageChange(1)
}
</script> </script>
<template> <template>
<div v-if="error">{{ $t('bookform.error', { error: error.message }) }}</div> <div v-if="error">{{ $t('bookform.error', { error: error.message }) }}</div>
<div v-if="data"> <div v-if="data">
<h2 class="title">{{ data.name }}</h2> <h2 class="title">{{ data.name }}</h2>
<AddBookToCollection :collection-id="props.id" @created="fetchCollection" />
<div> <div>
<CollectionFormBookItem v-for="book in data.books" :key="book.id" v-bind="book" /> <CollectionFormBookItem v-for="book in data.books" :key="book.id" v-bind="book" />
</div> </div>

View File

@@ -36,7 +36,15 @@ function fetchData(searchTerm, authorId) {
error.value = null error.value = null
if (searchTerm != null) { if (searchTerm != null) {
const lang = navigator.language.substring(0, 2) const lang = navigator.language.substring(0, 2)
getSearchBooks(data, error, searchTerm, lang, forceSearchInventaire.value, limit, offset.value) getSearchBooks(
data,
error,
searchTerm,
lang,
forceSearchInventaire.value ? 2 : 1,
limit,
offset.value,
)
} else if (authorId != null) { } else if (authorId != null) {
getAuthorBooks(data, error, authorId, limit, offset.value) getAuthorBooks(data, error, authorId, limit, offset.value)
} }

View File

@@ -130,6 +130,14 @@ export function postCollection(collection) {
return genericPayloadCall('/ws/collection', collection, 'POST') return genericPayloadCall('/ws/collection', collection, 'POST')
} }
export function postCollectionAddBook(collectionId, bookId) {
return genericPayloadCall(
'/ws/collection/' + collectionId + '/addbook',
{ bookId: bookId },
'POST',
)
}
export function putBook(id, book) { export function putBook(id, book) {
return genericPayloadCall('/ws/book/edit/' + id, book.value, 'PUT') return genericPayloadCall('/ws/book/edit/' + id, book.value, 'PUT')
} }
@@ -219,10 +227,9 @@ export function genericPayloadCall(apiRoute, object, method) {
} }
export function extractFormErrorFromField(fieldName, errors) { export function extractFormErrorFromField(fieldName, errors) {
if (errors == null) { if (errors == null || typeof errors == 'undefined' || !Array.isArray(errors)) {
return '' return ''
} }
console.log(errors.value)
const titleErr = errors.find((e) => e['field'] === fieldName) const titleErr = errors.find((e) => e['field'] === fieldName)
if (typeof titleErr !== 'undefined') { if (typeof titleErr !== 'undefined') {
return titleErr.error return titleErr.error

View File

@@ -0,0 +1,64 @@
package apitest
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestPostCollectionBookHandler_Ok(t *testing.T) {
payload :=
`{
"bookId": 9
}`
collectionId := "1"
status := testPostCollectionBookHandler(t, collectionId, payload)
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, int64(7), collection.Count)
}
func TestPostCollectionBookHandler_CollectionNotFound(t *testing.T) {
payload :=
`{
"bookId": 9
}`
status := testPostCollectionBookHandler(t, "12", payload)
assert.Equal(t, http.StatusNotFound, status)
}
func TestPostCollectionBookHandler_BookNotFound(t *testing.T) {
payload :=
`{
"bookId": 14654
}`
status := testPostCollectionBookHandler(t, "1", payload)
assert.Equal(t, http.StatusNotFound, status)
}
func TestPostCollectionBookHandler_Unauthorized(t *testing.T) {
payload :=
`{
"bookId": 9
}`
status := testPostCollectionBookHandler(t, "3", payload)
assert.Equal(t, http.StatusUnauthorized, status)
}
func testPostCollectionBookHandler(t *testing.T, collectionId string, payload string) int {
router := testutils.TestSetup()
w := httptest.NewRecorder()
token := testutils.ConnectDemoUser(router)
req, _ := http.NewRequest("POST", "/ws/collection/"+collectionId+"/addbook",
strings.NewReader(payload))
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
router.ServeHTTP(w, req)
return w.Code
}

View File

@@ -3,6 +3,7 @@ package apitest
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -15,14 +16,14 @@ import (
) )
func TestSearchBook_MultipleBooks(t *testing.T) { func TestSearchBook_MultipleBooks(t *testing.T) {
result := testSearchBook(t, "san", "", "") result := testSearchBook(t, "san", "", "", dto.NoInventaireSearch)
assert.Equal(t, int64(2), result.Count) assert.Equal(t, int64(2), result.Count)
assert.Equal(t, 2, len(result.Books)) assert.Equal(t, 2, len(result.Books))
} }
func TestSearchBook_OneBookNotUserBook(t *testing.T) { func TestSearchBook_OneBookNotUserBook(t *testing.T) {
result := testSearchBook(t, "iliade", "", "") result := testSearchBook(t, "iliade", "", "", dto.NoInventaireSearch)
assert.Equal(t, int64(1), result.Count) assert.Equal(t, int64(1), result.Count)
assert.Equal(t, assert.Equal(t,
[]dto.BookItemGet{{ []dto.BookItemGet{{
@@ -38,7 +39,7 @@ func TestSearchBook_OneBookNotUserBook(t *testing.T) {
} }
func TestSearchBook_OneBookRead(t *testing.T) { func TestSearchBook_OneBookRead(t *testing.T) {
result := testSearchBook(t, "dieux", "", "") result := testSearchBook(t, "dieux", "", "", dto.NoInventaireSearch)
assert.Equal(t, int64(1), result.Count) assert.Equal(t, int64(1), result.Count)
assert.Equal(t, assert.Equal(t,
[]dto.BookItemGet{{ []dto.BookItemGet{{
@@ -55,7 +56,7 @@ func TestSearchBook_OneBookRead(t *testing.T) {
} }
func TestSearchBook_OneBookStartRead(t *testing.T) { func TestSearchBook_OneBookStartRead(t *testing.T) {
result := testSearchBook(t, "Recherches", "", "") result := testSearchBook(t, "Recherches", "", "", dto.NoInventaireSearch)
assert.Equal(t, int64(1), result.Count) assert.Equal(t, int64(1), result.Count)
assert.Equal(t, assert.Equal(t,
[]dto.BookItemGet{{ []dto.BookItemGet{{
@@ -72,7 +73,7 @@ func TestSearchBook_OneBookStartRead(t *testing.T) {
} }
func TestSearchBook_ISBN(t *testing.T) { func TestSearchBook_ISBN(t *testing.T) {
result := testSearchBook(t, "9782070337903", "", "") result := testSearchBook(t, "9782070337903", "", "", dto.NoInventaireSearch)
assert.Equal(t, int64(1), result.Count) assert.Equal(t, int64(1), result.Count)
assert.Equal(t, assert.Equal(t,
[]dto.BookItemGet{{ []dto.BookItemGet{{
@@ -88,7 +89,7 @@ func TestSearchBook_ISBN(t *testing.T) {
} }
func TestSearchBook_ISBNInventaire(t *testing.T) { func TestSearchBook_ISBNInventaire(t *testing.T) {
result := testSearchBook(t, "9782253158400", "", "") result := testSearchBook(t, "9782253158400", "", "", dto.InventaireIfNothingFound)
assert.Equal(t, int64(1), result.Count) assert.Equal(t, int64(1), result.Count)
assert.Equal(t, assert.Equal(t,
[]dto.BookItemGet{{ []dto.BookItemGet{{
@@ -107,17 +108,17 @@ func TestSearchBook_ISBNInventaire(t *testing.T) {
} }
func TestSearchBook_Limit(t *testing.T) { func TestSearchBook_Limit(t *testing.T) {
result := testSearchBook(t, "a", "10", "") result := testSearchBook(t, "a", "10", "", dto.NoInventaireSearch)
assert.Equal(t, 10, len(result.Books)) assert.Equal(t, 10, len(result.Books))
} }
func TestSearchBook_Offset(t *testing.T) { func TestSearchBook_Offset(t *testing.T) {
result := testSearchBook(t, "sa", "", "2") result := testSearchBook(t, "sa", "", "2", dto.NoInventaireSearch)
assert.Equal(t, int64(5), result.Count) assert.Equal(t, int64(5), result.Count)
assert.Equal(t, 3, len(result.Books)) assert.Equal(t, 3, len(result.Books))
} }
func testSearchBook(t *testing.T, searchterm string, limit string, offset string) dto.BookItemsGet { func testSearchBook(t *testing.T, searchterm string, limit string, offset string, inventaireSearchType dto.InventaireSearchType) dto.BookItemsGet {
router := testutils.TestSetup() router := testutils.TestSetup()
u, err := url.Parse("/ws/search/" + searchterm) u, err := url.Parse("/ws/search/" + searchterm)
@@ -137,6 +138,7 @@ func testSearchBook(t *testing.T, searchterm string, limit string, offset string
q := u.Query() q := u.Query()
q.Set("lang", "fr") q.Set("lang", "fr")
q.Set("inventaire", strconv.Itoa(int(inventaireSearchType)))
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
token := testutils.ConnectDemoUser(router) token := testutils.ConnectDemoUser(router)

View File

@@ -5,9 +5,17 @@ type AuthorGet struct {
Description string `json:"description"` Description string `json:"description"`
} }
type InventaireSearchType int
const (
NoInventaireSearch InventaireSearchType = iota
InventaireIfNothingFound
ForceInventaireSearch
)
type BookSearchGetParam struct { type BookSearchGetParam struct {
Lang string `form:"lang" binding:"max=5"` Lang string `form:"lang" binding:"max=5"`
Inventaire bool `form:"inventaire"` Inventaire InventaireSearchType `form:"inventaire"`
} }
type BookFields struct { type BookFields struct {
@@ -39,6 +47,10 @@ type CollectionFields struct {
Name string `json:"name" binding:"required,max=300"` Name string `json:"name" binding:"required,max=300"`
} }
type CollectionBook struct {
BookID uint `json:"bookId" binding:"required"`
}
type FileInfoPost struct { type FileInfoPost struct {
FileID uint `json:"fileId"` FileID uint `json:"fileId"`
FilePath string `json:"filepath"` FilePath string `json:"filepath"`

View File

@@ -57,6 +57,9 @@ func ReturnErrorsAsJsonResponse(ac *appcontext.AppContext, err error) {
ac.C.JSON(httpError.StatusCode, gin.H{"error": httpError.Err.Error()}) ac.C.JSON(httpError.StatusCode, gin.H{"error": httpError.Err.Error()})
return return
} }
if errors.Is(err, gorm.ErrRecordNotFound) {
ac.C.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
}
ac.C.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) ac.C.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} }

View File

@@ -38,7 +38,7 @@ func GetSearchBooksHandler(ac appcontext.AppContext) {
return return
} }
var returnedBooks dto.BookItemsGet var returnedBooks dto.BookItemsGet
if !params.Inventaire { if params.Inventaire != dto.ForceInventaireSearch {
books, err := query.FetchBookSearchGet(ac.Db, user.ID, searchterm, limit, offset) books, err := query.FetchBookSearchGet(ac.Db, user.ID, searchterm, limit, offset)
if err != nil { if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
@@ -51,7 +51,7 @@ func GetSearchBooksHandler(ac appcontext.AppContext) {
} }
returnedBooks = dto.BookItemsGet{Count: count, Inventaire: false, Books: books} returnedBooks = dto.BookItemsGet{Count: count, Inventaire: false, Books: books}
} }
if params.Inventaire || len(returnedBooks.Books) == 0 { if (params.Inventaire == dto.InventaireIfNothingFound && len(returnedBooks.Books) == 0) || (params.Inventaire == dto.ForceInventaireSearch) {
returnedBooksPtr, err := searchInInventaireAPI(ac.Config.InventaireUrl, searchterm, limit, offset, params) returnedBooksPtr, err := searchInInventaireAPI(ac.Config.InventaireUrl, searchterm, limit, offset, params)
if err != nil { if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)

View File

@@ -0,0 +1,62 @@
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"
"github.com/gin-gonic/gin"
)
func PostCollectionBookHandler(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 collectionBook dto.CollectionBook
err = ac.C.ShouldBindJSON(&collectionBook)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var book model.Book
err = ac.Db.First(&book, collectionBook.BookID).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
collection.Books = append(collection.Books, book)
ac.Db.Save(&collection)
ac.C.String(http.StatusOK, "Success")
}

View File

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