Book form: can now edit an existing book

This commit is contained in:
2026-04-01 00:34:09 +02:00
parent bcde39d51d
commit 8d97d00e93
11 changed files with 297 additions and 85 deletions

View File

@@ -1,11 +1,17 @@
<script setup> <script setup>
import { ref, reactive, computed } from 'vue' import { ref, reactive, computed } from 'vue'
import { postBook, extractFormErrorFromField } from './api.js' import { postBook, putBook, extractFormErrorFromField, getBookCall } from './api.js'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import CoverUpload from './CoverUpload.vue' import CoverUpload from './CoverUpload.vue'
const router = useRouter() const router = useRouter()
const props = defineProps({
id: String,
})
const fetchError = ref(null)
const book = ref({ const book = ref({
title: '', title: '',
author: '', author: '',
@@ -16,6 +22,31 @@ const book = ref({
summary: '', summary: '',
coverId: null, coverId: null,
}) })
if (props.id) {
getBookCall(props.id)
.then((res) => {
if (res.status === 401) {
const authStore = useAuthStore()
authStore.logout()
}
return res.json()
})
.then(fillBookWithJson)
.catch((err) => (fetchError.value = err))
}
function fillBookWithJson(json) {
book.value.title = json.title
book.value.author = json.author
book.value.isbn = json.isbn
book.value.inventaireid = json.inventaireid
book.value.openlibraryid = json.openlibraryid
book.value.shortdescription = json.shortdescription
book.value.summary = json.summary
book.value.coverId = json.coverId
}
const errors = ref(null) const errors = ref(null)
const titleError = computed(() => { const titleError = computed(() => {
return extractFormErrorFromField('Title', errors.value) return extractFormErrorFromField('Title', errors.value)
@@ -39,20 +70,39 @@ const summaryError = computed(() => {
return extractFormErrorFromField('ShortDescription', errors.value) return extractFormErrorFromField('ShortDescription', errors.value)
}) })
function postOrPutBook(book) {
if (props.id) {
return
} else {
return postBook(book)
}
}
function onSubmit(e) { function onSubmit(e) {
postBook(book).then((res) => { if (props.id) {
if (res.ok) { putBook(props.id, book).then((res) => {
res.json().then((json) => router.push('/book/' + json.id)) if (res.ok) {
return router.push('/book/' + props.id)
} else { } else {
res.json().then((json) => (errors.value = json)) res.json().then((json) => (errors.value = json))
} }
}) })
} else {
postBook(book).then((res) => {
if (res.ok) {
res.json().then((json) => router.push('/book/' + json.id))
return
} else {
res.json().then((json) => (errors.value = json))
}
})
}
} }
</script> </script>
<template> <template>
<form @submit.prevent="onSubmit"> <div v-if="error">{{ $t('bookform.error', { error: fetchError.message }) }}</div>
<form v-else @submit.prevent="onSubmit">
<div class="field"> <div class="field">
<label class="label">{{ $t('addbook.title') }}</label> <label class="label">{{ $t('addbook.title') }}</label>
<div class="control"> <div class="control">

View File

@@ -11,7 +11,7 @@ import {
putEndReadDateUnset, putEndReadDateUnset,
putUnreadBook, putUnreadBook,
} from './api.js' } from './api.js'
import { useRouter, onBeforeRouteUpdate } from 'vue-router' import { useRouter, onBeforeRouteUpdate, RouterLink } from 'vue-router'
import { VRating } from 'vuetify/components/VRating' import { VRating } from 'vuetify/components/VRating'
import BookFormIcons from './BookFormIcons.vue' import BookFormIcons from './BookFormIcons.vue'
import ReviewWidget from './ReviewWidget.vue' import ReviewWidget from './ReviewWidget.vue'
@@ -105,6 +105,14 @@ function goToAuthor() {
<figure class="image"> <figure class="image">
<img v-bind:src="imagePathOrDefault" v-bind:alt="data.title" /> <img v-bind:src="imagePathOrDefault" v-bind:alt="data.title" />
</figure> </figure>
<div class="centered mt-2">
<RouterLink :to="'/book/' + props.id + '/edit'">
<span>
<b-icon-pencil-fill />
</span>
Modifier le livre
</RouterLink>
</div>
</div> </div>
<div class="column"> <div class="column">
<h3 class="title">{{ data.title }}</h3> <h3 class="title">{{ data.title }}</h3>

View File

@@ -18,28 +18,34 @@ export function getImagePathOrGivenDefault(path, defaultpath) {
} }
} }
function useFetch(data, error, url) { function userFetch(url) {
const { user } = useAuthStore() const { user } = useAuthStore()
if (user != null) { if (user != null) {
fetch(url, { return fetch(url, {
method: 'GET', method: 'GET',
headers: { headers: {
Authorization: 'Bearer ' + user.token, Authorization: 'Bearer ' + user.token,
}, },
}) })
.then((res) => { } else {
if (res.status === 401) { return Promise.resolve()
const authStore = useAuthStore()
authStore.logout()
}
return res.json()
})
.then((json) => (data.value = json))
.catch((err) => (error.value = err))
} }
} }
function useFetch(data, error, url) {
userFetch(url)
.then((res) => {
if (res.status === 401) {
const authStore = useAuthStore()
authStore.logout()
}
return res.json()
})
.then((json) => (data.value = json))
.catch((err) => (error.value = err))
}
export async function getAppInfo(appInfo, appInfoErr) { export async function getAppInfo(appInfo, appInfoErr) {
return fetch('/ws/appinfo', { return fetch('/ws/appinfo', {
method: 'GET', method: 'GET',
@@ -98,6 +104,10 @@ export function getBook(data, error, id) {
return useFetch(data, error, '/ws/book/' + id) return useFetch(data, error, '/ws/book/' + id)
} }
export function getBookCall(id) {
return userFetch('/ws/book/' + id)
}
export function postBook(book) { export function postBook(book) {
return genericPayloadCall('/ws/book', book.value, 'POST') return genericPayloadCall('/ws/book', book.value, 'POST')
} }
@@ -106,6 +116,10 @@ export async function postImportBook(id, language) {
return genericPayloadCall('/ws/importbook', { inventaireid: id, lang: language }, 'POST') return genericPayloadCall('/ws/importbook', { inventaireid: id, lang: language }, 'POST')
} }
export function putBook(id, book) {
return genericPayloadCall('/ws/book/edit/' + id, book.value, 'PUT')
}
export async function putReadBook(bookId) { export async function putReadBook(bookId) {
return putEndReadDate(bookId, new Date().toISOString().slice(0, 10)) return putEndReadDate(bookId, new Date().toISOString().slice(0, 10))
} }

View File

@@ -19,6 +19,7 @@ const routes = [
{ path: '/browse', component: InstanceBrowser }, { path: '/browse', component: InstanceBrowser },
{ path: '/books', component: BooksBrowser }, { path: '/books', component: BooksBrowser },
{ path: '/book/:id', component: BookFormView, props: true }, { path: '/book/:id', component: BookFormView, props: true },
{ path: '/book/:id/edit', component: BookFormEdit, props: true },
{ path: '/author/:id', component: AuthorForm, props: true }, { path: '/author/:id', component: AuthorForm, props: true },
{ path: '/search/:searchterm', component: SearchBook, props: true }, { path: '/search/:searchterm', component: SearchBook, props: true },
{ path: '/import/inventaire/:inventaireid', component: ImportInventaire, props: true }, { path: '/import/inventaire/:inventaireid', component: ImportInventaire, props: true },

View File

@@ -0,0 +1,63 @@
package adapter
import (
"errors"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/model"
"gorm.io/gorm"
)
func FillBookDbFromFields(ac appcontext.AppContext, fields *dto.BookFields, book *model.Book) error {
if fields.Title != nil {
book.Title = *fields.Title
}
if fields.ISBN != nil {
book.ISBN = *fields.ISBN
}
if fields.InventaireID != nil {
book.InventaireID = *fields.InventaireID
}
if fields.OpenLibraryId != nil {
book.OpenLibraryId = *fields.OpenLibraryId
}
if fields.ShortDescription != nil {
book.SmallDescription = *fields.ShortDescription
}
if fields.Summary != nil {
book.Summary = *fields.Summary
}
if fields.CoverID != nil {
book.CoverID = *fields.CoverID
}
if fields.Author != nil {
author, err := fetchOrCreateAuthor(ac, *fields.Author)
if err != nil {
return err
}
book.AuthorID = author.ID
}
return nil
}
func fetchOrCreateAuthor(ac appcontext.AppContext, name string) (*model.Author, error) {
var author model.Author
res := ac.Db.Where("name = ?", name).First(&author)
err := res.Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
author = model.Author{Name: name}
err = ac.Db.Save(&author).Error
if err != nil {
return &author, err
}
return &author, nil
} else {
return &author, err
}
} else {
return &author, nil
}
}

View File

@@ -9,7 +9,6 @@ import (
"strings" "strings"
"testing" "testing"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/testutils" "git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -62,20 +61,12 @@ func TestPostBookHandler_AllFields(t *testing.T) {
id := testPostBookHandler(t, bookJson, 200) id := testPostBookHandler(t, bookJson, 200)
createdBook := testGetBook(t, strconv.FormatUint(uint64(id), 10), http.StatusOK) createdBook := testGetBook(t, strconv.FormatUint(uint64(id), 10), http.StatusOK)
assert.Equal(t, assert.Equal(t, "Amerika", createdBook.Title)
dto.FullBookGet{ assert.Equal(t, "Kafka", createdBook.Author)
Title: "Amerika", assert.Equal(t, "978-2-07-036803-7", createdBook.ISBN)
Author: "Kafka", assert.Equal(t, "isbn:9782070368037", createdBook.InventaireId)
AuthorID: 27, assert.Equal(t, "OL8838048M", createdBook.OpenLibraryId)
ISBN: "978-2-07-036803-7", assert.Equal(t, "L'Amérique (Amerika en version originale allemande) ou Le Disparu (Der Verschollene, titre voulu par l'auteur et rendu au livre dans ses plus récentes éditions) est le premier roman de Franz Kafka (1883-1924).", createdBook.Summary)
InventaireId: "isbn:9782070368037",
OpenLibraryId: "OL8838048M",
Summary: "L'Amérique (Amerika en version originale allemande) ou Le Disparu (Der Verschollene, titre voulu par l'auteur et rendu au livre dans ses plus récentes éditions) est le premier roman de Franz Kafka (1883-1924).",
Rating: 0,
Read: false,
CoverPath: "",
Review: "",
}, createdBook)
} }
func TestPostBookHandler_TitleTooLong(t *testing.T) { func TestPostBookHandler_TitleTooLong(t *testing.T) {

View File

@@ -0,0 +1,56 @@
package apitest
import (
"net/http"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestPutBookHandler_TitleChange(t *testing.T) {
bookId := "17"
bookJson :=
`{
"title": "Le coup de pistolaid"
}`
testPutBook(t, bookJson, bookId, 200)
modifiedBook := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, "Le coup de pistolaid", modifiedBook.Title)
}
func TestPutBookHandler_Author(t *testing.T) {
bookId := "17"
bookJson :=
`{
"author": "Alexander Pouchkine"
}`
testPutBook(t, bookJson, bookId, 200)
modifiedBook := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, "Alexander Pouchkine", modifiedBook.Author)
}
func TestPutBookHandler_MultipleFields(t *testing.T) {
bookId := "17"
bookJson :=
`{
"title": "Le pistolet",
"author": "Pouchkine",
"isbn": "978-2-07-036803-7",
"inventaireid": "isbn:9782070368037",
"openlibraryid": "OL8838048M",
"shortdescription": "Roman de Pouchkine",
"summary": "En garnison dans une petite ville, un officier de l'armée impériale russe rencontre Silvio, ancien soldat et tireur exceptionnel. Celui-ci fait forte impression sur lui, jusqu'au jour où il refuse, à la suite d'un affront, de se battre en duel."
}`
testPutBook(t, bookJson, bookId, 200)
modifiedBook := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, "Le pistolet", modifiedBook.Title)
assert.Equal(t, "Pouchkine", modifiedBook.Author)
assert.Equal(t, "978-2-07-036803-7", modifiedBook.ISBN)
assert.Equal(t, "OL8838048M", modifiedBook.OpenLibraryId)
assert.Equal(t, "En garnison dans une petite ville, un officier de l'armée impériale russe rencontre Silvio, ancien soldat et tireur exceptionnel. Celui-ci fait forte impression sur lui, jusqu'au jour où il refuse, à la suite d'un affront, de se battre en duel.", modifiedBook.Summary)
}
func testPutBook(t *testing.T, payload string, bookId string, expectedCode int) {
testutils.TestBookPutCallWithDemoPayload(t, payload, bookId, expectedCode, "/ws/book/edit/"+bookId)
}

View File

@@ -10,15 +10,15 @@ type BookSearchGetParam struct {
Inventaire bool `form:"inventaire"` Inventaire bool `form:"inventaire"`
} }
type BookPostCreate struct { type BookFields struct {
Title string `json:"title" binding:"required,max=300"` Title *string `json:"title" binding:"omitempty,max=300"`
Author string `json:"author" binding:"max=100"` Author *string `json:"author" binding:"omitempty,max=100"`
ISBN string `json:"isbn" binding:"max=18"` ISBN *string `json:"isbn" binding:"omitempty,max=18"`
InventaireID string `json:"inventaireid" binding:"max=50"` InventaireID *string `json:"inventaireid" binding:"omitempty,max=50"`
OpenLibraryId string `json:"openlibraryid" binding:"max=50"` OpenLibraryId *string `json:"openlibraryid" binding:"omitempty,max=50"`
ShortDescription string `json:"shortdescription" binding:"max=300"` ShortDescription *string `json:"shortdescription" binding:"omitempty,max=300"`
Summary string `json:"summary"` Summary *string `json:"summary"`
CoverID uint `json:"coverId"` CoverID *uint `json:"coverId"`
} }
type BookPostImport struct { type BookPostImport struct {

View File

@@ -4,26 +4,38 @@ import (
"errors" "errors"
"net/http" "net/http"
"git.artlef.fr/bibliomane/internal/adapter"
"git.artlef.fr/bibliomane/internal/appcontext" "git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto" "git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/i18nresource"
"git.artlef.fr/bibliomane/internal/model" "git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator" "git.artlef.fr/bibliomane/internal/myvalidator"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
func PostBookHandler(ac appcontext.AppContext) { func PostBookHandler(ac appcontext.AppContext) {
var book dto.BookPostCreate var book dto.BookFields
err := ac.C.ShouldBindJSON(&book) err := ac.C.ShouldBindJSON(&book)
if err != nil { if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return return
} }
err = myvalidator.ValidateId(ac.Db, book.CoverID, &model.StaticFile{}) //when creating a book, title is required
if err != nil { if book.Title == nil {
err = myvalidator.HttpError{
StatusCode: http.StatusBadRequest,
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "ValidationRequired")),
}
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return return
} }
if book.CoverID != nil {
err = myvalidator.ValidateId(ac.Db, *book.CoverID, &model.StaticFile{})
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
}
user, fetchUserErr := ac.GetAuthenticatedUser() user, fetchUserErr := ac.GetAuthenticatedUser()
if fetchUserErr != nil { if fetchUserErr != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
@@ -38,44 +50,15 @@ func PostBookHandler(ac appcontext.AppContext) {
ac.C.JSON(http.StatusOK, gin.H{"id": id}) ac.C.JSON(http.StatusOK, gin.H{"id": id})
} }
func saveBookToDb(ac appcontext.AppContext, b dto.BookPostCreate, user *model.User) (uint, error) { func saveBookToDb(ac appcontext.AppContext, b dto.BookFields, user *model.User) (uint, error) {
author, err := fetchOrCreateAuthor(ac, b.Author) book := model.Book{
AddedBy: *user,
}
err := adapter.FillBookDbFromFields(ac, &b, &book)
if err != nil { if err != nil {
return 0, err return 0, err
} }
book := model.Book{
Title: b.Title,
AuthorID: author.ID,
ISBN: b.ISBN,
InventaireID: b.InventaireID,
OpenLibraryId: b.OpenLibraryId,
SmallDescription: b.ShortDescription,
Summary: b.Summary,
AddedBy: *user,
}
if b.CoverID > 0 {
book.CoverID = b.CoverID
}
err = ac.Db.Save(&book).Error err = ac.Db.Save(&book).Error
return book.ID, err return book.ID, err
} }
func fetchOrCreateAuthor(ac appcontext.AppContext, name string) (*model.Author, error) {
var author model.Author
res := ac.Db.Where("name = ?", name).First(&author)
err := res.Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
author = model.Author{Name: name}
err = ac.Db.Save(&author).Error
if err != nil {
return &author, err
}
return &author, nil
} else {
return &author, err
}
} else {
return &author, nil
}
}

View File

@@ -0,0 +1,43 @@
package routes
import (
"net/http"
"strconv"
"git.artlef.fr/bibliomane/internal/adapter"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator"
"github.com/gin-gonic/gin"
)
func PutBookHandler(ac appcontext.AppContext) {
bookId64, err := strconv.ParseUint(ac.C.Param("id"), 10, 64)
bookId := uint(bookId64)
if err != nil {
ac.C.JSON(http.StatusBadRequest, gin.H{"error": err})
return
}
var book model.Book
err = ac.Db.First(&book, bookId).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var bookPut dto.BookFields
err = ac.C.ShouldBindJSON(&bookPut)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
err = adapter.FillBookDbFromFields(ac, &bookPut, &book)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
ac.Db.Save(&book)
ac.C.String(http.StatusOK, "Success")
}

View File

@@ -63,6 +63,9 @@ func Setup(config *config.Config) *gin.Engine {
ws.PUT("/book/:id", func(c *gin.Context) { ws.PUT("/book/:id", func(c *gin.Context) {
routes.PutUserBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) routes.PutUserBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
}) })
ws.PUT("/book/edit/:id", func(c *gin.Context) {
routes.PutBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.POST("/book", func(c *gin.Context) { ws.POST("/book", func(c *gin.Context) {
routes.PostBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) routes.PostBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
}) })