From 159323197d4d3b971817217b193cbde442a4ca91 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 10 Oct 2026 16:33:08 +0200 Subject: [PATCH] fix(16.2): CR-01 serve content_html when a locale's content is blank A translation that carries only content_html (an API write through FormatTranslation) opened as an empty editor, and the first edit in that locale silently replaced the translated body. Load now serves the htmlColumn attribute whenever the stored content is blank and that attribute is not empty; an untouched save echoes it and is skipped, an edit writes the edited HTML to both columns. --- classes/blog_editor.go | 13 +++++- classes/blog_editor_test.go | 63 ++++++++++++++++++++++++-- controllers/posts_editor_smoke_test.go | 39 ++++++++++++++++ 3 files changed, 109 insertions(+), 6 deletions(-) diff --git a/classes/blog_editor.go b/classes/blog_editor.go index ba33063..35c8b90 100644 --- a/classes/blog_editor.go +++ b/classes/blog_editor.go @@ -66,7 +66,8 @@ var ( // keeps (SanitizeHTMLReport) and reports what it removed as notices; it // never refuses a string because of its markup. Load serves HTML content as // it is and a markdown value (one that does not start with '<') as the -// rendered HTML of the htmlColumn option. Upload stores an image the editor +// rendered HTML of the htmlColumn option, and a blank value as the +// htmlColumn attribute when that holds a body. Upload stores an image the editor // inserts through StoreMedia, in the media bucket the app publishes. app is // asked for the booted application at request time; it may be nil in tests, // where Upload then fails. @@ -201,10 +202,15 @@ func removalNames(names []string, cut bool) string { // served as it is. A markdown value is served as its rendered HTML: the // htmlColumn attribute in the same locale, or FormatHTML of the value when // that is empty or cannot be read (the value itself when rendering fails). +// A blank value is served as the htmlColumn attribute in the same locale +// when that is not empty, so a locale that carries only content_html (an +// API-written translation, FormatTranslation) opens with the body the site +// renders instead of an empty editor whose first edit would replace it; it +// is served as stored otherwise. func loadBlogHTML(_ context.Context, in pact.FormFieldValue) (any, error) { text := storedText(in.Value) column, _ := in.Options["htmlColumn"].(string) - if column == "" || LooksLikeHTML(text) || strings.TrimSpace(text) == "" { + if column == "" || LooksLikeHTML(text) { return text, nil } if in.Translated != nil { @@ -216,6 +222,9 @@ func loadBlogHTML(_ context.Context, in pact.FormFieldValue) (any, error) { return html, nil } } + if strings.TrimSpace(text) == "" { + return text, nil + } html, err := FormatHTML(text) if err != nil { return text, nil diff --git a/classes/blog_editor_test.go b/classes/blog_editor_test.go index 24dc2d7..34f5486 100644 --- a/classes/blog_editor_test.go +++ b/classes/blog_editor_test.go @@ -110,14 +110,69 @@ func TestBlogEditorLoad(t *testing.T) { t.Run("nil and empty values give the empty string", func(t *testing.T) { for _, value := range []any{nil, (*string)(nil), &empty, "", " \n"} { - got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{Options: blogOptions, Value: value, Translated: noTranslated(t)}) - want := storedText(value) - if err != nil || got != want { - t.Fatalf("Load(%#v) = %q, %v; want %q", value, got, err, want) + for _, translated := range []func(string) (string, bool, error){ + nil, + translatedReader(new([]string), "", false, nil), + translatedReader(new([]string), "", true, nil), + } { + got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{Options: blogOptions, Value: value, Translated: translated}) + want := storedText(value) + if err != nil || got != want { + t.Fatalf("Load(%#v) = %q, %v; want %q", value, got, err, want) + } } } }) + // CR-01: a locale whose content is blank but whose content_html holds a + // body (an API-written translation, FormatTranslation) opens with that + // body, not an empty editor whose first edit would replace it. + t.Run("blank content is served as that locale's content_html", func(t *testing.T) { + const translatedBody = "

Tłumaczenie

\n

Treść z API.

\n" + for _, tc := range []struct { + name string + value any + locale string + isDefault bool + }{ + {"translation with empty content", "", "en", false}, + {"translation with blank content", " \n\t", "en", false}, + {"host row with nil content", nil, "pl", true}, + {"host row with a nil *string", (*string)(nil), "pl", true}, + } { + var asked []string + got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{ + Options: blogOptions, Locale: tc.locale, Default: tc.isDefault, Value: tc.value, + Translated: translatedReader(&asked, translatedBody, true, nil), + }) + if err != nil || got != translatedBody { + t.Fatalf("%s: Load = %q, %v; want the stored content_html", tc.name, got, err) + } + if len(asked) != 1 || asked[0] != "content_html" { + t.Fatalf("%s: asked %v, want [content_html]", tc.name, asked) + } + } + }) + + t.Run("blank content with a failing content_html read is the Load error", func(t *testing.T) { + boom := errors.New("read failed") + _, err := loadBlogHTML(context.Background(), pact.FormFieldValue{ + Options: blogOptions, Locale: "en", Value: "", Translated: translatedReader(new([]string), "", false, boom), + }) + if !errors.Is(err, boom) { + t.Fatalf("err = %v, want %v", err, boom) + } + }) + + t.Run("blank content without htmlColumn never reads another attribute", func(t *testing.T) { + got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{ + Options: map[string]any{"uploadFolder": "categories"}, Value: "", Translated: noTranslated(t), + }) + if err != nil || got != "" { + t.Fatalf("Load = %q, %v; want the empty string", got, err) + } + }) + t.Run("a *string is dereferenced", func(t *testing.T) { got, err := loadBlogHTML(context.Background(), pact.FormFieldValue{Value: &html}) if err != nil || got != html { diff --git a/controllers/posts_editor_smoke_test.go b/controllers/posts_editor_smoke_test.go index 57425bc..3f9f763 100644 --- a/controllers/posts_editor_smoke_test.go +++ b/controllers/posts_editor_smoke_test.go @@ -236,6 +236,45 @@ func TestBlogEditorTracerSmoke(t *testing.T) { } }) + // CR-01: a translation that carries only content_html opens with that + // body. An untouched save echoes the served value, which cabana skips; + // an edit writes the edited HTML to both content and content_html. + t.Run("blank content opens with content_html and an edit writes both columns", func(t *testing.T) { + const apiBody = "

Translated

\n

Written through the API.

\n" + served := load(t, "", func(attribute string) (string, bool, error) { + if attribute != "content_html" { + t.Fatalf("Translated(%q), want content_html", attribute) + } + return apiBody, true, nil + }) + if served != apiBody { + t.Fatalf("Load(blank content) = %q, want the stored content_html", served) + } + + const edited = "

Translated

\n

Edited in the admin.

\n" + decoded, err := ml.Decode(context.Background(), pact.FormFieldValue{Field: "content", Locale: "en", Value: edited}) + if err != nil || decoded.Value != edited { + t.Fatalf("Decode(edited) = %#v, %v", decoded, err) + } + writer := &recordingWriter{} + app := backpack.New(nil) + if err := app.Publish[cabana.TranslationWriter](writer); err != nil { + t.Fatal(err) + } + ctl := postsAdminController{app: func() *backpack.App { return app }} + ctx := bouncer.WithUser(context.Background(), editorPrincipal()) + ctx = cabana.WithFieldWrites(ctx, map[string][]cabana.FieldWrite{ + "content": {{Locale: "en", Value: decoded.Value}}, + }) + post := &models.Post{Content: "

Host

", ContentHTML: "

Host

"} + if err := ctl.FormAfterUpdate(ctx, post); err != nil { + t.Fatal(err) + } + if len(writer.writes) != 1 || writer.writes[0] != (recordedWrite{field: "content_html", locale: "en", value: edited}) { + t.Fatalf("translation writes = %#v, want en content_html = the edited value", writer.writes) + } + }) + t.Run("list value", func(t *testing.T) { got := ml.ListValue(context.Background(), "

Title

Some bold\n text

") if got != "Title Some bold text" {