UX: Name message fields messageId/messageParams in i18n payloads #5682

Rename the i18n notification/error envelope fields from id/params to messageId/messageParams across the WS notify Data and the i18n.Response body, so they are unambiguous next to session, log, and correlation ids. Updates the frontend consumers (notify.vue, api.js), tests, swagger, and the event/locales READMEs.
This commit is contained in:
Michael Mayer 2026-06-23 12:22:30 +00:00
parent 2fd9e93247
commit fd8448d6de
9 changed files with 39 additions and 38 deletions

View file

@ -15,9 +15,9 @@ It's source code can be obtained on [GitHub](https://github.com/vslavik/poedit).
Only asynchronous notifications and certain API responses need translation to provide a
consistent user experience.
Asynchronous notifications are additionally rendered by the web frontend in each user's current
UI language (using the message identifier); the `default.po` translation here serves as a fallback
and for non-browser consumers such as the CLI.
Asynchronous notifications and user-facing API error responses are additionally rendered by the web
frontend in each user's current UI language (using the message identifier); the `default.po` translation
here serves as a fallback and for non-browser consumers such as the CLI.
Technical log messages should be in English to avoid ambiguities and (even slightly) wrong translations.
`default.po` files in subdirectories contain localized messages for each

View file

@ -111,9 +111,9 @@ $api.interceptors.response.use(
code = data.code;
}
if (data.id) {
if (data.messageId) {
// Render the backend message in the current UI locale from its source id and params.
errorMessage = Tp(data.id, data.params);
errorMessage = Tp(data.messageId, data.messageParams);
} else if (data.message) {
errorMessage = data.message;
} else if (data.error) {

View file

@ -63,7 +63,7 @@ export default {
// Render in the current UI language when the backend sent a message id (the English source
// string); otherwise fall back to the pre-rendered message string as-is.
let m = data.id ? Tp(data.id, data.params) : data.message;
let m = data.messageId ? Tp(data.messageId, data.messageParams) : data.message;
// Skip empty messages.
if (!m || !m.length) {

View file

@ -3435,13 +3435,13 @@
"error": {
"type": "string"
},
"id": {
"type": "string"
},
"message": {
"type": "string"
},
"params": {
"messageId": {
"type": "string"
},
"messageParams": {
"items": {},
"type": "array"
}

View file

@ -20,11 +20,12 @@ event.Warn("low disk space")
```
The `*Msg` helpers (`SuccessMsg`/`ErrorMsg`/`InfoMsg`/`WarnMsg`) publish a structured payload
`Data{"message", "id", "params"}`: `message` is the server-rendered string in the instance locale,
`id` is the untranslated source string (`i18n.Source(id)`), and `params` are the substitution values.
The Web UI renders the notification from `id` + `params` in each user's current UI language; `message`
is a fallback. The plain `Success`/`Error`/`Info`/`Warn` string forms publish only `message` and are
**not** localized — reserve them for already-translated or non-user-facing text.
`Data{"message", "messageId", "messageParams"}`: `message` is the server-rendered string in the
instance locale, `messageId` is the untranslated source string (`i18n.Source(id)`), and
`messageParams` are the substitution values. The Web UI renders the notification from `messageId` +
`messageParams` in each user's current UI language; `message` is a fallback. The plain
`Success`/`Error`/`Info`/`Warn` string forms publish only `message` and are **not** localized —
reserve them for already-translated or non-user-facing text.
Subscribe to topics:
```go

View file

@ -45,7 +45,7 @@ func Warn(msg string) {
func publishMsg(level logrus.Level, topic string, id i18n.Message, params ...any) {
msg := i18n.Msg(id, params...)
Log.Log(level, strings.ToLower(msg))
Publish(topic, Data{"message": msg, "id": i18n.Source(id), "params": params})
Publish(topic, Data{"message": msg, "messageId": i18n.Source(id), "messageParams": params})
}
// ErrorMsg publishes a localized error notification.

View file

@ -15,8 +15,8 @@ func TestSuccessMsg(t *testing.T) {
msg := <-s.Receiver
assert.Equal(t, "notify.success", msg.Name)
assert.Equal(t, "Album Holiday deleted", msg.Fields["message"])
assert.Equal(t, "Album %s deleted", msg.Fields["id"])
assert.Equal(t, []any{"Holiday"}, msg.Fields["params"])
assert.Equal(t, "Album %s deleted", msg.Fields["messageId"])
assert.Equal(t, []any{"Holiday"}, msg.Fields["messageParams"])
Unsubscribe(s)
})
t.Run("WithoutParams", func(t *testing.T) {
@ -25,7 +25,7 @@ func TestSuccessMsg(t *testing.T) {
msg := <-s.Receiver
assert.Equal(t, "notify.success", msg.Name)
assert.Equal(t, "Album created", msg.Fields["message"])
assert.Equal(t, "Album created", msg.Fields["id"])
assert.Equal(t, "Album created", msg.Fields["messageId"])
Unsubscribe(s)
})
}
@ -36,8 +36,8 @@ func TestErrorMsg(t *testing.T) {
msg := <-s.Receiver
assert.Equal(t, "notify.error", msg.Name)
assert.Equal(t, "A cat already exists", msg.Fields["message"])
assert.Equal(t, "%s already exists", msg.Fields["id"])
assert.Equal(t, []any{"A cat"}, msg.Fields["params"])
assert.Equal(t, "%s already exists", msg.Fields["messageId"])
assert.Equal(t, []any{"A cat"}, msg.Fields["messageParams"])
Unsubscribe(s)
}
@ -47,8 +47,8 @@ func TestInfoMsg(t *testing.T) {
msg := <-s.Receiver
assert.Equal(t, "notify.info", msg.Name)
assert.Equal(t, "Indexing files in /photos", msg.Fields["message"])
assert.Equal(t, "Indexing files in %s", msg.Fields["id"])
assert.Equal(t, []any{"/photos"}, msg.Fields["params"])
assert.Equal(t, "Indexing files in %s", msg.Fields["messageId"])
assert.Equal(t, []any{"/photos"}, msg.Fields["messageParams"])
Unsubscribe(s)
}
@ -58,6 +58,6 @@ func TestWarnMsg(t *testing.T) {
msg := <-s.Receiver
assert.Equal(t, "notify.warning", msg.Name)
assert.Equal(t, "Busy, please try again later", msg.Fields["message"])
assert.Equal(t, "Busy, please try again later", msg.Fields["id"])
assert.Equal(t, "Busy, please try again later", msg.Fields["messageId"])
Unsubscribe(s)
}

View file

@ -5,15 +5,15 @@ import "strings"
// Response represents an i18n-aware response payload.
//
// Err/Msg carry the server-rendered string in the instance locale (a fallback for non-browser
// consumers); ID and Params carry the untranslated source id and its parameters so the Web UI can
// render the message in each user's current UI locale.
// consumers); MessageID and MessageParams carry the untranslated source string and its parameters
// so the Web UI can render the message in each user's current UI locale.
type Response struct {
Code int `json:"code"`
Err string `json:"error,omitempty"`
Msg string `json:"message,omitempty"`
Details string `json:"details,omitempty"`
ID string `json:"id,omitempty"`
Params []any `json:"params,omitempty"`
Code int `json:"code"`
Err string `json:"error,omitempty"`
Msg string `json:"message,omitempty"`
MessageID string `json:"messageId,omitempty"`
MessageParams []any `json:"messageParams,omitempty"`
Details string `json:"details,omitempty"`
}
func (r Response) String() string {
@ -42,7 +42,7 @@ func (r Response) Success() bool {
// It carries the untranslated source string (ID) and Params so the frontend can render the message
// in the current UI locale, in addition to the server-rendered Err/Msg fallback.
func NewResponse(code int, id Message, params ...any) Response {
r := Response{Code: code, ID: Source(id), Params: params}
r := Response{Code: code, MessageID: Source(id), MessageParams: params}
if code < 400 {
r.Msg = Msg(id, params...)
} else {

View file

@ -14,27 +14,27 @@ func TestNewResponse(t *testing.T) {
assert.Equal(t, http.StatusConflict, resp.Code)
assert.Equal(t, "A cat already exists", resp.Err)
assert.Equal(t, "", resp.Msg)
assert.Equal(t, "%s already exists", resp.ID)
assert.Equal(t, []any{"A cat"}, resp.Params)
assert.Equal(t, "%s already exists", resp.MessageID)
assert.Equal(t, []any{"A cat"}, resp.MessageParams)
})
t.Run("UnexpectedError", func(t *testing.T) {
resp := NewResponse(http.StatusInternalServerError, ErrUnexpected, "A cat")
assert.Equal(t, http.StatusInternalServerError, resp.Code)
assert.Equal(t, "Something went wrong, try again", resp.Err)
assert.Equal(t, "", resp.Msg)
assert.Equal(t, "Something went wrong, try again", resp.ID)
assert.Equal(t, "Something went wrong, try again", resp.MessageID)
})
t.Run("ChangesSaved", func(t *testing.T) {
resp := NewResponse(http.StatusOK, MsgChangesSaved)
assert.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, "", resp.Err)
assert.Equal(t, "Changes successfully saved", resp.Msg)
assert.Equal(t, "Changes successfully saved", resp.ID)
assert.Equal(t, "Changes successfully saved", resp.MessageID)
if s, err := json.Marshal(resp); err != nil {
t.Fatal(err)
} else {
assert.Equal(t, `{"code":200,"message":"Changes successfully saved","id":"Changes successfully saved"}`, string(s))
assert.Equal(t, `{"code":200,"message":"Changes successfully saved","messageId":"Changes successfully saved"}`, string(s))
}
})
}