edit feature

This commit is contained in:
Niko Abeler 2023-07-25 21:31:03 +02:00
parent a610515d5e
commit e628735ab2
10 changed files with 119 additions and 225 deletions

View File

@ -38,7 +38,7 @@ func TestEditorFormGet(t *testing.T) {
app := owlApp.FiberApp app := owlApp.FiberApp
token := getUserToken(owlApp.AuthorService) token := getUserToken(owlApp.AuthorService)
req := httptest.NewRequest("GET", "/editor/Image", nil) req := httptest.NewRequest("GET", "/editor/new/Image", nil)
req.AddCookie(&http.Cookie{Name: "token", Value: token}) req.AddCookie(&http.Cookie{Name: "token", Value: token})
resp, err := app.Test(req) resp, err := app.Test(req)
require.NoError(t, err) require.NoError(t, err)
@ -50,7 +50,7 @@ func TestEditorFormGetNoAuth(t *testing.T) {
owlApp := App(db) owlApp := App(db)
app := owlApp.FiberApp app := owlApp.FiberApp
req := httptest.NewRequest("GET", "/editor/Image", nil) req := httptest.NewRequest("GET", "/editor/new/Image", nil)
req.AddCookie(&http.Cookie{Name: "token", Value: "invalid"}) req.AddCookie(&http.Cookie{Name: "token", Value: "invalid"})
resp, err := app.Test(req) resp, err := app.Test(req)
require.NoError(t, err) require.NoError(t, err)
@ -83,7 +83,7 @@ func TestEditorFormPost(t *testing.T) {
io.WriteString(part, "test content") io.WriteString(part, "test content")
writer.Close() writer.Close()
req := httptest.NewRequest("POST", "/editor/Image", body) req := httptest.NewRequest("POST", "/editor/new/Image", body)
req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Content-Type", writer.FormDataContentType())
req.AddCookie(&http.Cookie{Name: "token", Value: token}) req.AddCookie(&http.Cookie{Name: "token", Value: token})
resp, err := app.Test(req) resp, err := app.Test(req)
@ -125,7 +125,7 @@ func TestEditorFormPostNoAuth(t *testing.T) {
io.WriteString(part, "test content") io.WriteString(part, "test content")
writer.Close() writer.Close()
req := httptest.NewRequest("POST", "/editor/Image", body) req := httptest.NewRequest("POST", "/editor/new/Image", body)
req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Content-Type", writer.FormDataContentType())
req.AddCookie(&http.Cookie{Name: "token", Value: "invalid"}) req.AddCookie(&http.Cookie{Name: "token", Value: "invalid"})
resp, err := app.Test(req) resp, err := app.Test(req)

View File

@ -18,7 +18,7 @@
<ul> <ul>
{{range .Types}} {{range .Types}}
<li><a href="/editor/{{.}}">{{.}}</a></li> <li><a href="/editor/new/{{.}}/">{{.}}</a></li>
{{end}} {{end}}
{{end}} {{end}}

View File

@ -29,6 +29,13 @@
</div> </div>
{{ if .LoggedIn }}
<br>
<br>
<br>
<a href="/editor/edit/{{.Entry.ID}}/" role="button" class="secondary outline">Edit</a>
{{ end }}
{{end}} {{end}}

View File

@ -36,6 +36,7 @@ func NewWebApp(
configRegister *app.ConfigRegister, configRegister *app.ConfigRegister,
) *WebApp { ) *WebApp {
app := fiber.New() app := fiber.New()
app.Use(middleware.NewUserMiddleware(authorService).Handle)
indexHandler := NewIndexHandler(entryService, configRepo) indexHandler := NewIndexHandler(entryService, configRepo)
listHandler := NewListHandler(entryService, configRepo) listHandler := NewListHandler(entryService, configRepo)
@ -62,8 +63,10 @@ func NewWebApp(
editor := app.Group("/editor") editor := app.Group("/editor")
editor.Use(middleware.NewAuthMiddleware(authorService).Handle) editor.Use(middleware.NewAuthMiddleware(authorService).Handle)
editor.Get("/", editorListHandler.Handle) editor.Get("/", editorListHandler.Handle)
editor.Get("/:editor/", editorHandler.HandleGet) editor.Get("/new/:editor/", editorHandler.HandleGetNew)
editor.Post("/:editor/", editorHandler.HandlePost) editor.Post("/new/:editor/", editorHandler.HandlePostNew)
editor.Get("/edit/:id/", editorHandler.HandleGetEdit)
editor.Post("/edit/:id/", editorHandler.HandlePostEdit)
// SiteConfig // SiteConfig
siteConfig := app.Group("/site-config") siteConfig := app.Group("/site-config")

View File

@ -1,171 +0,0 @@
package editor
import (
"fmt"
"mime/multipart"
"owl-blogs/app"
"owl-blogs/domain/model"
"reflect"
"strings"
)
type HttpFormData interface {
// FormFile returns the first file by key from a MultipartForm.
FormFile(key string) (*multipart.FileHeader, error)
// FormValue returns the first value by key from a MultipartForm.
// Search is performed in QueryArgs, PostArgs, MultipartForm and FormFile in this particular order.
// Defaults to the empty string "" if the form value doesn't exist.
// If a default value is given, it will return that value if the form value does not exist.
// Returned value is only valid within the handler. Do not store any references.
// Make copies or use the Immutable setting instead.
FormValue(key string, defaultValue ...string) string
}
type EditorEntryForm struct {
entry model.Entry
binSvc *app.BinaryService
}
type EntryFormFieldParams struct {
InputType string
Widget string
}
type EntryFormField struct {
Name string
Params EntryFormFieldParams
}
func NewEntryForm(entry model.Entry, binaryService *app.BinaryService) *EditorEntryForm {
return &EditorEntryForm{
entry: entry,
binSvc: binaryService,
}
}
func (s *EntryFormFieldParams) ApplyTag(tagKey string, tagValue string) error {
switch tagKey {
case "inputType":
s.InputType = tagValue
case "widget":
s.Widget = tagValue
default:
return fmt.Errorf("unknown tag key: %v", tagKey)
}
return nil
}
func (s *EntryFormField) Html() string {
html := ""
html += fmt.Sprintf("<label for=\"%v\">%v</label>\n", s.Name, s.Name)
if s.Params.InputType == "text" && s.Params.Widget == "textarea" {
html += fmt.Sprintf("<textarea name=\"%v\" id=\"%v\" rows=\"20\"></textarea>\n", s.Name, s.Name)
} else {
html += fmt.Sprintf("<input type=\"%v\" name=\"%v\" id=\"%v\" />\n", s.Params.InputType, s.Name, s.Name)
}
return html
}
func FieldToFormField(field reflect.StructField) (EntryFormField, error) {
formField := EntryFormField{
Name: field.Name,
Params: EntryFormFieldParams{},
}
tag := field.Tag.Get("owl")
for _, param := range strings.Split(tag, " ") {
parts := strings.Split(param, "=")
if len(parts) != 2 {
continue
}
err := formField.Params.ApplyTag(parts[0], parts[1])
if err != nil {
return EntryFormField{}, err
}
}
return formField, nil
}
func StructToFormFields(meta interface{}) ([]EntryFormField, error) {
entryType := reflect.TypeOf(meta).Elem()
numFields := entryType.NumField()
fields := []EntryFormField{}
for i := 0; i < numFields; i++ {
field, err := FieldToFormField(entryType.Field(i))
if err != nil {
return nil, err
}
fields = append(fields, field)
}
return fields, nil
}
func (s *EditorEntryForm) HtmlForm() (string, error) {
meta := s.entry.MetaData()
fields, err := StructToFormFields(meta)
if err != nil {
return "", err
}
html := "<form method=\"POST\" enctype=\"multipart/form-data\">\n"
for _, field := range fields {
html += field.Html()
}
html += "<input type=\"submit\" value=\"Submit\" />\n"
html += "</form>\n"
return html, nil
}
func (s *EditorEntryForm) Parse(ctx HttpFormData) (model.Entry, error) {
if ctx == nil {
return nil, fmt.Errorf("nil context")
}
meta := s.entry.MetaData()
metaVal := reflect.ValueOf(meta)
if metaVal.Kind() != reflect.Ptr {
return nil, fmt.Errorf("meta data is not a pointer")
}
fields, err := StructToFormFields(meta)
if err != nil {
return nil, err
}
for _, field := range fields {
fieldName := field.Name
if field.Params.InputType == "file" {
file, err := ctx.FormFile(fieldName)
if err != nil {
return nil, err
}
fileData, err := file.Open()
if err != nil {
return nil, err
}
defer fileData.Close()
fileBytes := make([]byte, file.Size)
_, err = fileData.Read(fileBytes)
if err != nil {
return nil, err
}
binaryFile, err := s.binSvc.Create(file.Filename, fileBytes)
if err != nil {
return nil, err
}
metaField := metaVal.Elem().FieldByName(fieldName)
if metaField.IsValid() {
metaField.SetString(binaryFile.Id)
}
} else {
formValue := ctx.FormValue(fieldName)
metaField := metaVal.Elem().FieldByName(fieldName)
if metaField.IsValid() {
metaField.SetString(formValue)
}
}
}
return s.entry, nil
}

View File

@ -5,7 +5,7 @@ import (
"owl-blogs/app/repository" "owl-blogs/app/repository"
"owl-blogs/domain/model" "owl-blogs/domain/model"
"owl-blogs/render" "owl-blogs/render"
"owl-blogs/web/editor" "owl-blogs/web/forms"
"time" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@ -41,7 +41,7 @@ func (h *EditorHandler) paramToEntry(c *fiber.Ctx) (model.Entry, error) {
return entryType, nil return entryType, nil
} }
func (h *EditorHandler) HandleGet(c *fiber.Ctx) error { func (h *EditorHandler) HandleGetNew(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
entryType, err := h.paramToEntry(c) entryType, err := h.paramToEntry(c)
@ -49,7 +49,7 @@ func (h *EditorHandler) HandleGet(c *fiber.Ctx) error {
return err return err
} }
form := editor.NewEntryForm(entryType, h.binSvc) form := forms.NewForm(entryType.MetaData(), h.binSvc)
htmlForm, err := form.HtmlForm() htmlForm, err := form.HtmlForm()
if err != nil { if err != nil {
return err return err
@ -57,23 +57,24 @@ func (h *EditorHandler) HandleGet(c *fiber.Ctx) error {
return render.RenderTemplateWithBase(c, getSiteConfig(h.configRepo), "views/editor", htmlForm) return render.RenderTemplateWithBase(c, getSiteConfig(h.configRepo), "views/editor", htmlForm)
} }
func (h *EditorHandler) HandlePost(c *fiber.Ctx) error { func (h *EditorHandler) HandlePostNew(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
entryType, err := h.paramToEntry(c) entry, err := h.paramToEntry(c)
if err != nil { if err != nil {
return err return err
} }
form := editor.NewEntryForm(entryType, h.binSvc) form := forms.NewForm(entry.MetaData(), h.binSvc)
// get form data // get form data
entry, err := form.Parse(c) entryMeta, err := form.Parse(c)
if err != nil { if err != nil {
return err return err
} }
// create entry // create entry
now := time.Now() now := time.Now()
entry.SetMetaData(entryMeta)
entry.SetPublishedAt(&now) entry.SetPublishedAt(&now)
entry.SetAuthorId(c.Locals("author").(string)) entry.SetAuthorId(c.Locals("author").(string))
@ -84,3 +85,45 @@ func (h *EditorHandler) HandlePost(c *fiber.Ctx) error {
return c.Redirect("/posts/" + entry.ID() + "/") return c.Redirect("/posts/" + entry.ID() + "/")
} }
func (h *EditorHandler) HandleGetEdit(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
id := c.Params("id")
entry, err := h.entrySvc.FindById(id)
if err != nil {
return err
}
form := forms.NewForm(entry.MetaData(), h.binSvc)
htmlForm, err := form.HtmlForm()
if err != nil {
return err
}
return render.RenderTemplateWithBase(c, getSiteConfig(h.configRepo), "views/editor", htmlForm)
}
func (h *EditorHandler) HandlePostEdit(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
id := c.Params("id")
entry, err := h.entrySvc.FindById(id)
if err != nil {
return err
}
form := forms.NewForm(entry.MetaData(), h.binSvc)
// get form data
meta, err := form.Parse(c)
if err != nil {
return err
}
// update entry
entry.SetMetaData(meta)
err = h.entrySvc.Update(entry)
if err != nil {
return err
}
return c.Redirect("/posts/" + entry.ID() + "/")
}

View File

@ -17,8 +17,9 @@ type EntryHandler struct {
} }
type entryData struct { type entryData struct {
Entry model.Entry Entry model.Entry
Author *model.Author Author *model.Author
LoggedIn bool
} }
func NewEntryHandler( func NewEntryHandler(
@ -49,5 +50,14 @@ func (h *EntryHandler) Handle(c *fiber.Ctx) error {
author = &model.Author{} author = &model.Author{}
} }
return render.RenderTemplateWithBase(c, getSiteConfig(h.configRepo), "views/entry", entryData{Entry: entry, Author: author}) return render.RenderTemplateWithBase(
c,
getSiteConfig(h.configRepo),
"views/entry",
entryData{
Entry: entry,
Author: author,
LoggedIn: c.Locals("author") != nil,
},
)
} }

View File

@ -135,6 +135,14 @@ func (s *Form) Parse(ctx HttpFormData) (interface{}, error) {
if field.Params.InputType == "file" { if field.Params.InputType == "file" {
file, err := ctx.FormFile(fieldName) file, err := ctx.FormFile(fieldName)
if err != nil { if err != nil {
// If field already has a value, we can ignore the error
if field.Value != "" {
metaField := dataVal.Elem().FieldByName(fieldName)
if metaField.IsValid() {
metaField.SetString(field.Value)
}
continue
}
return nil, err return nil, err
} }
fileData, err := file.Open() fileData, err := file.Open()

View File

@ -1,4 +1,4 @@
package editor_test package forms_test
import ( import (
"bytes" "bytes"
@ -6,10 +6,9 @@ import (
"mime/multipart" "mime/multipart"
"os" "os"
"owl-blogs/app" "owl-blogs/app"
"owl-blogs/domain/model"
"owl-blogs/infra" "owl-blogs/infra"
"owl-blogs/test" "owl-blogs/test"
"owl-blogs/web/editor" "owl-blogs/web/forms"
"path" "path"
"path/filepath" "path/filepath"
"reflect" "reflect"
@ -18,7 +17,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
type MockEntryMetaData struct { type MockData struct {
Image string `owl:"inputType=file"` Image string `owl:"inputType=file"`
Content string `owl:"inputType=text"` Content string `owl:"inputType=text"`
} }
@ -65,37 +64,16 @@ func (f *MockFormData) FormValue(key string, defaultValue ...string) string {
return key return key
} }
type MockEntry struct {
model.EntryBase
metaData MockEntryMetaData
}
func (e *MockEntry) Content() model.EntryContent {
return model.EntryContent(e.metaData.Content)
}
func (e *MockEntry) MetaData() interface{} {
return &e.metaData
}
func (e *MockEntry) SetMetaData(metaData interface{}) {
e.metaData = *metaData.(*MockEntryMetaData)
}
func (e *MockEntry) Title() string {
return ""
}
func TestFieldToFormField(t *testing.T) { func TestFieldToFormField(t *testing.T) {
field := reflect.TypeOf(&MockEntryMetaData{}).Elem().Field(0) field := reflect.TypeOf(&MockData{}).Elem().Field(0)
formField, err := editor.FieldToFormField(field) formField, err := forms.FieldToFormField(field, "")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "Image", formField.Name) require.Equal(t, "Image", formField.Name)
require.Equal(t, "file", formField.Params.InputType) require.Equal(t, "file", formField.Params.InputType)
} }
func TestStructToFields(t *testing.T) { func TestStructToFields(t *testing.T) {
fields, err := editor.StructToFormFields(&MockEntryMetaData{}) fields, err := forms.StructToFormFields(&MockData{})
require.NoError(t, err) require.NoError(t, err)
require.Len(t, fields, 2) require.Len(t, fields, 2)
require.Equal(t, "Image", fields[0].Name) require.Equal(t, "Image", fields[0].Name)
@ -104,8 +82,8 @@ func TestStructToFields(t *testing.T) {
require.Equal(t, "text", fields[1].Params.InputType) require.Equal(t, "text", fields[1].Params.InputType)
} }
func TestEditorEntryForm_HtmlForm(t *testing.T) { func TestForm_HtmlForm(t *testing.T) {
form := editor.NewEntryForm(&MockEntry{}, nil) form := forms.NewForm(&MockData{}, nil)
html, err := form.HtmlForm() html, err := form.HtmlForm()
require.NoError(t, err) require.NoError(t, err)
require.Contains(t, html, "<form") require.Contains(t, html, "<form")
@ -117,7 +95,7 @@ func TestEditorEntryForm_HtmlForm(t *testing.T) {
} }
func TestFormParseNil(t *testing.T) { func TestFormParseNil(t *testing.T) {
form := editor.NewEntryForm(&MockEntry{}, nil) form := forms.NewForm(&MockData{}, nil)
_, err := form.Parse(nil) _, err := form.Parse(nil)
require.Error(t, err) require.Error(t, err)
} }
@ -125,9 +103,9 @@ func TestFormParseNil(t *testing.T) {
func TestFormParse(t *testing.T) { func TestFormParse(t *testing.T) {
binRepo := infra.NewBinaryFileRepo(test.NewMockDb()) binRepo := infra.NewBinaryFileRepo(test.NewMockDb())
binService := app.NewBinaryFileService(binRepo) binService := app.NewBinaryFileService(binRepo)
form := editor.NewEntryForm(&MockEntry{}, binService) form := forms.NewForm(&MockData{}, binService)
entry, err := form.Parse(NewMockFormData()) data, err := form.Parse(NewMockFormData())
require.NoError(t, err) require.NoError(t, err)
require.NotZero(t, entry.MetaData().(*MockEntryMetaData).Image) require.NotZero(t, data.(*MockData).Image)
require.Equal(t, "Content", entry.MetaData().(*MockEntryMetaData).Content) require.Equal(t, "Content", data.(*MockData).Content)
} }

View File

@ -10,21 +10,37 @@ type AuthMiddleware struct {
authorService *app.AuthorService authorService *app.AuthorService
} }
type UserMiddleware struct {
authorService *app.AuthorService
}
func NewAuthMiddleware(authorService *app.AuthorService) *AuthMiddleware { func NewAuthMiddleware(authorService *app.AuthorService) *AuthMiddleware {
return &AuthMiddleware{authorService: authorService} return &AuthMiddleware{authorService: authorService}
} }
func NewUserMiddleware(authorService *app.AuthorService) *UserMiddleware {
return &UserMiddleware{authorService: authorService}
}
func (m *AuthMiddleware) Handle(c *fiber.Ctx) error { func (m *AuthMiddleware) Handle(c *fiber.Ctx) error {
if c.Locals("author") == nil {
return c.Redirect("/auth/login")
}
return c.Next()
}
func (m *UserMiddleware) Handle(c *fiber.Ctx) error {
// get token from cookie // get token from cookie
token := c.Cookies("token") token := c.Cookies("token")
if token == "" { if token == "" {
return c.Redirect("/auth/login") return c.Next()
} }
// check token // check token
valid, name := m.authorService.ValidateToken(token) valid, name := m.authorService.ValidateToken(token)
if !valid { if !valid {
return c.Redirect("/auth/login") return c.Next()
} }
// set author name to context // set author name to context