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" {