From ccd63e7a2e45458daac0baf6af8871af8a359e40 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Tue, 19 Jul 2022 22:24:03 -0500 Subject: [PATCH] Add editing of education resources --- src/hmns3/hmns3.go | 4 +- ...022-06-23T032752Z_AddEducationResources.go | 2 +- src/migration/seed.go | 6 +- src/models/education.go | 4 +- src/templates/mapping.go | 12 +- src/templates/src/editor.html | 9 +- src/templates/src/education_article.html | 2 +- src/templates/types.go | 1 + src/utils/utils.go | 2 +- src/website/education.go | 281 ++++++++++++------ 10 files changed, 217 insertions(+), 106 deletions(-) diff --git a/src/hmns3/hmns3.go b/src/hmns3/hmns3.go index 91db660..a39a5e6 100644 --- a/src/hmns3/hmns3.go +++ b/src/hmns3/hmns3.go @@ -29,7 +29,7 @@ func StartServer(ctx context.Context) jobs.Job { return jobs.Noop() } - utils.Must0(os.MkdirAll(dir, fs.ModePerm)) + utils.Must(os.MkdirAll(dir, fs.ModePerm)) s := server{ log: logging.ExtractLogger(ctx).With(). @@ -84,7 +84,7 @@ func (s *server) putObject(w http.ResponseWriter, r *http.Request) { bucket, key := bucketKey(r) w.Header().Set("Location", fmt.Sprintf("/%s", bucket)) - utils.Must0(os.MkdirAll(filepath.Join(dir, bucket), fs.ModePerm)) + utils.Must(os.MkdirAll(filepath.Join(dir, bucket), fs.ModePerm)) if key != "" { file := utils.Must1(os.Create(filepath.Join(dir, bucket, key))) io.Copy(file, r.Body) diff --git a/src/migration/migrations/2022-06-23T032752Z_AddEducationResources.go b/src/migration/migrations/2022-06-23T032752Z_AddEducationResources.go index 325b201..ed1bfc1 100644 --- a/src/migration/migrations/2022-06-23T032752Z_AddEducationResources.go +++ b/src/migration/migrations/2022-06-23T032752Z_AddEducationResources.go @@ -37,7 +37,7 @@ func (m AddEducationResources) Up(ctx context.Context, tx pgx.Tx) error { CREATE TABLE education_article ( id SERIAL NOT NULL PRIMARY KEY, title VARCHAR(255) NOT NULL, - slug VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, description TEXT NOT NULL, type INT NOT NULL, published BOOLEAN NOT NULL DEFAULT FALSE, diff --git a/src/migration/seed.go b/src/migration/seed.go index 0abad6e..e4bc8df 100644 --- a/src/migration/seed.go +++ b/src/migration/seed.go @@ -64,7 +64,7 @@ func BareMinimumSeed() *models.Project { fmt.Println("Creating HMN project...") hmn := seedProject(ctx, tx, seedHMN, nil) - utils.Must0(tx.Commit(ctx)) + utils.Must(tx.Commit(ctx)) return hmn } @@ -166,7 +166,7 @@ func SampleSeed() { // Finally, set sequence numbers to things that won't conflict utils.Must1(tx.Exec(ctx, "SELECT setval('project_id_seq', 100, true);")) - utils.Must0(tx.Commit(ctx)) + utils.Must(tx.Commit(ctx)) } func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models.User { @@ -198,7 +198,7 @@ func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models. utils.OrDefault(input.Name, randomName()), utils.OrDefault(input.Bio, lorem.Paragraph(0, 2)), utils.OrDefault(input.Blurb, lorem.Sentence(0, 14)), utils.OrDefault(input.Signature, lorem.Sentence(0, 16)), input.ShowEmail, ) - utils.Must0(auth.SetPassword(ctx, conn, input.Username, "password")) + utils.Must(auth.SetPassword(ctx, conn, input.Username, "password")) return user } diff --git a/src/models/education.go b/src/models/education.go index afa4f28..26a4bb9 100644 --- a/src/models/education.go +++ b/src/models/education.go @@ -1,6 +1,8 @@ package models -import "time" +import ( + "time" +) type EduArticle struct { ID int `db:"id"` diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 24a17ff..d964115 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -492,7 +492,7 @@ func TagToTemplate(t *models.Tag) Tag { } } -func EducationArticleToTemplate(a *models.EduArticle, currentVersion *models.EduArticleVersion) EduArticle { +func EducationArticleToTemplate(a *models.EduArticle) EduArticle { res := EduArticle{ Title: a.Title, Slug: a.Slug, @@ -505,9 +505,15 @@ func EducationArticleToTemplate(a *models.EduArticle, currentVersion *models.Edu Content: "NO CONTENT HERE FOLKS YOU DID A BUG", } + switch a.Type { + case models.EduArticleTypeArticle: + res.Type = "article" + case models.EduArticleTypeGlossary: + res.Type = "glossary" + } - if currentVersion != nil { - res.Content = template.HTML(currentVersion.ContentHTML) + if a.CurrentVersion != nil { + res.Content = template.HTML(a.CurrentVersion.ContentHTML) } return res diff --git a/src/templates/src/editor.html b/src/templates/src/editor.html index 4d9b452..3519996 100644 --- a/src/templates/src/editor.html +++ b/src/templates/src/editor.html @@ -81,21 +81,22 @@ {{ end }} {{ if .ShowEduOptions }} + {{/* Hope you have a .Article field! */}}
- +
- +
{{ end }} diff --git a/src/templates/src/education_article.html b/src/templates/src/education_article.html index 5d4c4c9..033dad6 100644 --- a/src/templates/src/education_article.html +++ b/src/templates/src/education_article.html @@ -1,5 +1,5 @@ {{ template "base.html" . }} {{ define "content" }} - {{ .Content }} + {{ .Article.Content }} {{ end }} diff --git a/src/templates/types.go b/src/templates/types.go index 3977704..292ef61 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -390,6 +390,7 @@ type EduArticle struct { Slug string Description string Published bool + Type string Url string EditUrl string diff --git a/src/utils/utils.go b/src/utils/utils.go index b8d73f7..6df2c0d 100644 --- a/src/utils/utils.go +++ b/src/utils/utils.go @@ -22,7 +22,7 @@ func OrDefault[T comparable](v T, def T) T { // Takes an (error) return and panics if there is an error. // Helps avoid `if err != nil` in scripts. Use sparingly in real code. -func Must0(err error) { +func Must(err error) { if err != nil { panic(err) } diff --git a/src/website/education.go b/src/website/education.go index 1e39f96..b6b387e 100644 --- a/src/website/education.go +++ b/src/website/education.go @@ -1,10 +1,11 @@ package website import ( + "context" "errors" "fmt" - "html/template" "net/http" + "net/url" "time" "git.handmade.network/hmn/hmn/src/db" @@ -13,6 +14,7 @@ import ( "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/parsing" "git.handmade.network/hmn/hmn/src/templates" + "git.handmade.network/hmn/hmn/src/utils" ) func EducationIndex(c *RequestContext) ResponseData { @@ -46,28 +48,10 @@ func EducationGlossary(c *RequestContext) ResponseData { func EducationArticle(c *RequestContext) ResponseData { type articleData struct { templates.BaseData - - Content template.HTML + Article templates.EduArticle } - type articleResult struct { - Article models.EduArticle `db:"a"` - CurrentVersion models.EduArticleVersion `db:"v"` - } - - article, err := db.QueryOne[articleResult](c, c.Conn, - ` - SELECT $columns - FROM - education_article AS a - JOIN education_article_version AS v ON a.current_version = v.id - WHERE - slug = $1 - AND type = $2 - `, - c.PathParams["slug"], - models.EduArticleTypeArticle, - ) + article, err := fetchEduArticle(c, c.Conn, c.PathParams["slug"], models.EduArticleTypeArticle) if errors.Is(err, db.NotFound) { return FourOhFour(c) } else if err != nil { @@ -76,7 +60,7 @@ func EducationArticle(c *RequestContext) ResponseData { tmpl := articleData{ BaseData: getBaseData(c, "Handmade Education", nil), - Content: template.HTML(article.CurrentVersion.ContentHTML), + Article: templates.EducationArticleToTemplate(article), } var res ResponseData @@ -93,7 +77,7 @@ func EducationAdmin(c *RequestContext) ResponseData { var tmplArticles []templates.EduArticle var tmplGlossaryTerms []templates.EduArticle for _, a := range articles { - tmpl := templates.EducationArticleToTemplate(&a.Article, &a.CurrentVersion) + tmpl := templates.EducationArticleToTemplate(&a.Article) switch a.Article.Type { case models.EduArticleTypeArticle: tmplArticles = append(tmplArticles, tmpl) @@ -122,6 +106,7 @@ func EducationAdmin(c *RequestContext) ResponseData { func EducationArticleNew(c *RequestContext) ResponseData { type adminData struct { editorData + Article map[string]interface{} } tmpl := adminData{ @@ -140,82 +125,69 @@ func EducationArticleNewSubmit(c *RequestContext) ResponseData { return c.ErrorResponse(http.StatusBadRequest, err) } - var redirectUrl string + art, ver := getEduArticleFromForm(form) - title := form.Get("title") - slug := form.Get("slug") - description := form.Get("description") - var eduType models.EduArticleType - switch form.Get("type") { - case "article": - eduType = models.EduArticleTypeArticle - redirectUrl = hmnurl.BuildEducationArticle(slug) - case "glossary": - eduType = models.EduArticleTypeGlossary - redirectUrl = hmnurl.BuildEducationGlossary(slug) - default: - panic(fmt.Errorf("unknown education article type: %s", form.Get("type"))) - } - published := form.Get("published") != "" - body := form.Get("body") - - // TODO: Education-specific Markdown - bodyRendered := parsing.ParseMarkdown(body, parsing.ForumRealMarkdown) - - tx, err := c.Conn.Begin(c) - if err != nil { - panic(err) - } - defer tx.Rollback(c) - { - dupe := 0 < db.MustQueryOneScalar[int](c, tx, - ` - SELECT COUNT(*) FROM education_article - WHERE slug = $1 AND type = $2 - `, - slug, eduType, - ) - if dupe { - return c.RejectRequest("A resource already exists with that slug and type.") - } - - articleID := db.MustQueryOneScalar[int](c, tx, - ` - INSERT INTO education_article (title, slug, description, published, type, current_version) - VALUES ($1, $2, $3, $4, $5, -1) - RETURNING id - `, - title, slug, description, published, eduType, - ) - versionID := db.MustQueryOneScalar[int](c, tx, - ` - INSERT INTO education_article_version (article_id, date, editor_id, content_raw, content_html) - VALUES ($1, $2, $3, $4, $5 ) - RETURNING id - `, - articleID, time.Now(), c.CurrentUser.ID, body, bodyRendered, - ) - tx.Exec(c, - `UPDATE education_article SET current_version = $1 WHERE id = $2`, - versionID, articleID, - ) - } - err = tx.Commit(c) - if err != nil { - panic(err) + dupe := 0 < db.MustQueryOneScalar[int](c, c.Conn, + ` + SELECT COUNT(*) FROM education_article + WHERE slug = $1 + `, + art.Slug, + ) + if dupe { + return c.RejectRequest("A resource already exists with that slug.") } - return c.Redirect(redirectUrl, http.StatusSeeOther) + createEduArticle(c, art, ver) + + res := c.Redirect(eduArticleURL(&art), http.StatusSeeOther) + res.AddFutureNotice("success", "Created new education article.") + return res } func EducationArticleEdit(c *RequestContext) ResponseData { - // TODO - panic("not implemented yet") + type adminData struct { + editorData + Article templates.EduArticle + } + + article, err := fetchEduArticle(c, c.Conn, c.PathParams["slug"], 0) + if errors.Is(err, db.NotFound) { + return FourOhFour(c) + } else if err != nil { + panic(err) + } + + tmpl := adminData{ + editorData: getEditorDataForEduArticle(c.UrlContext, c.CurrentUser, getBaseData(c, "Edit Education Article", nil), article), + Article: templates.EducationArticleToTemplate(article), + } + tmpl.editorData.SubmitUrl = hmnurl.BuildEducationArticleEdit(c.PathParams["slug"]) + + var res ResponseData + res.MustWriteTemplate("editor.html", tmpl, c.Perf) + return res } func EducationArticleEditSubmit(c *RequestContext) ResponseData { - // TODO - panic("not implemented yet") + form, err := c.GetFormValues() + if err != nil { + return c.ErrorResponse(http.StatusBadRequest, err) + } + + _, err = fetchEduArticle(c, c.Conn, c.PathParams["slug"], 0) + if errors.Is(err, db.NotFound) { + return FourOhFour(c) + } else if err != nil { + panic(err) + } + + art, ver := getEduArticleFromForm(form) + updateEduArticle(c, c.PathParams["slug"], art, ver) + + res := c.Redirect(eduArticleURL(&art), http.StatusSeeOther) + res.AddFutureNotice("success", "Edited education article.") + return res } func EducationArticleDelete(c *RequestContext) ResponseData { @@ -228,6 +200,37 @@ func EducationArticleDeleteSubmit(c *RequestContext) ResponseData { panic("not implemented yet") } +func fetchEduArticle(ctx context.Context, dbConn db.ConnOrTx, slug string, t models.EduArticleType) (*models.EduArticle, error) { + type eduArticleResult struct { + Article models.EduArticle `db:"a"` + CurrentVersion models.EduArticleVersion `db:"v"` + } + + var qb db.QueryBuilder + qb.Add( + ` + SELECT $columns + FROM + education_article AS a + JOIN education_article_version AS v ON a.current_version = v.id + WHERE + slug = $? + `, + slug, + ) + if t != 0 { + qb.Add(`AND type = $?`, t) + } + + res, err := db.QueryOne[eduArticleResult](ctx, dbConn, qb.String(), qb.Args()...) + if err != nil { + return nil, err + } + + res.Article.CurrentVersion = &res.CurrentVersion + return &res.Article, nil +} + func getEditorDataForEduArticle( urlContext *hmnurl.UrlContext, currentUser *models.User, @@ -244,5 +247,103 @@ func getEditorDataForEduArticle( ShowEduOptions: true, } + if article != nil { + result.PostTitle = article.Title + result.EditInitialContents = article.CurrentVersion.ContentRaw + } + return result } + +func getEduArticleFromForm(form url.Values) (art models.EduArticle, ver models.EduArticleVersion) { + art.Title = form.Get("title") + art.Slug = form.Get("slug") + art.Description = form.Get("description") + switch form.Get("type") { + case "article": + art.Type = models.EduArticleTypeArticle + case "glossary": + art.Type = models.EduArticleTypeGlossary + default: + panic(fmt.Errorf("unknown education article type: %s", form.Get("type"))) + } + art.Published = form.Get("published") != "" + + // TODO: Education-specific Markdown + ver.ContentRaw = form.Get("body") + ver.ContentHTML = parsing.ParseMarkdown(ver.ContentRaw, parsing.ForumRealMarkdown) + + return +} + +func createEduArticle(c *RequestContext, art models.EduArticle, ver models.EduArticleVersion) { + tx := utils.Must1(c.Conn.Begin(c)) + defer tx.Rollback(c) + { + articleID := db.MustQueryOneScalar[int](c, tx, + ` + INSERT INTO education_article (title, slug, description, published, type, current_version) + VALUES ($1, $2, $3, $4, $5, -1) + RETURNING id + `, + art.Title, art.Slug, art.Description, art.Published, art.Type, + ) + versionID := db.MustQueryOneScalar[int](c, tx, + ` + INSERT INTO education_article_version (article_id, date, editor_id, content_raw, content_html) + VALUES ($1, $2, $3, $4, $5 ) + RETURNING id + `, + articleID, time.Now(), c.CurrentUser.ID, ver.ContentRaw, ver.ContentHTML, + ) + tx.Exec(c, + `UPDATE education_article SET current_version = $1 WHERE id = $2`, + versionID, articleID, + ) + } + utils.Must(tx.Commit(c)) +} + +func updateEduArticle(c *RequestContext, slug string, art models.EduArticle, ver models.EduArticleVersion) { + tx := utils.Must1(c.Conn.Begin(c)) + defer tx.Rollback(c) + { + articleID := db.MustQueryOneScalar[int](c, tx, + `SELECT id FROM education_article WHERE slug = $1`, + slug, + ) + versionID := db.MustQueryOneScalar[int](c, tx, + ` + INSERT INTO education_article_version (article_id, date, editor_id, content_raw, content_html) + VALUES ($1, $2, $3, $4, $5 ) + RETURNING id + `, + articleID, time.Now(), c.CurrentUser.ID, ver.ContentRaw, ver.ContentHTML, + ) + tx.Exec(c, + ` + UPDATE education_article + SET + title = $1, slug = $2, description = $3, published = $4, type = $5, + current_version = $6 + WHERE + id = $7 + `, + art.Title, art.Slug, art.Description, art.Published, art.Type, + versionID, + articleID, + ) + } + utils.Must(tx.Commit(c)) +} + +func eduArticleURL(a *models.EduArticle) string { + switch a.Type { + case models.EduArticleTypeArticle: + return hmnurl.BuildEducationArticle(a.Slug) + case models.EduArticleTypeGlossary: + return hmnurl.BuildEducationGlossary(a.Slug) + default: + panic("unknown education article type") + } +}