mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
UX: Render backend error responses in the current UI locale #5682
The i18n.Response envelope now carries the untranslated source id and params; the frontend (api.js) renders them via Tp in the user's UI locale, with the server-rendered error/message as the instance-locale fallback. Common not-found/conflict/bad-request handlers were switched from hardcoded literals and raw err.Error() to Abort/i18n.Message helpers, so they benefit automatically.
This commit is contained in:
parent
49c6535c81
commit
f5373ccec6
11 changed files with 56 additions and 32 deletions
|
|
@ -25,7 +25,7 @@ Additional information can be found in our Developer Guide:
|
|||
|
||||
import Axios from "axios";
|
||||
import $notify from "common/notify";
|
||||
import { $gettext } from "common/gettext";
|
||||
import { $gettext, Tp } from "common/gettext";
|
||||
import $event from "common/event";
|
||||
import { getAppStorage } from "common/storage";
|
||||
|
||||
|
|
@ -111,7 +111,10 @@ $api.interceptors.response.use(
|
|||
code = data.code;
|
||||
}
|
||||
|
||||
if (data.message) {
|
||||
if (data.id) {
|
||||
// Render the backend message in the current UI locale from its source id and params.
|
||||
errorMessage = Tp(data.id, data.params);
|
||||
} else if (data.message) {
|
||||
errorMessage = data.message;
|
||||
} else if (data.error) {
|
||||
errorMessage = data.error;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/i18n"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
|
|
@ -73,7 +74,7 @@ func GetDownload(router *gin.RouterGroup) {
|
|||
f, err := query.FileByHash(id)
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(404, gin.H{"error": err.Error()})
|
||||
Abort(c, http.StatusNotFound, i18n.ErrFileNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ func TestGetDownload(t *testing.T) {
|
|||
GetDownload(router)
|
||||
r := PerformRequest(app, "GET", "/api/v1/dl/123xxx?t="+conf.DownloadToken())
|
||||
val := gjson.Get(r.Body.String(), "error")
|
||||
assert.Equal(t, "record not found", val.String())
|
||||
assert.Equal(t, "File not found", val.String())
|
||||
assert.Equal(t, http.StatusNotFound, r.Code)
|
||||
})
|
||||
t.Run("MissingOriginal", func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ func LikeLabel(router *gin.RouterGroup) {
|
|||
label, err := query.LabelByUID(id)
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())})
|
||||
Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +151,7 @@ func DislikeLabel(router *gin.RouterGroup) {
|
|||
label, err := query.LabelByUID(id)
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())})
|
||||
Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import (
|
|||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/i18n"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
// UpdateLink updates a share link and return it as JSON.
|
||||
|
|
@ -53,13 +52,13 @@ func UpdateLink(c *gin.Context) {
|
|||
|
||||
if frm.Password != "" {
|
||||
if err := link.SetPassword(frm.Password); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UpperFirst(err.Error())})
|
||||
Abort(c, http.StatusConflict, i18n.ErrSaveFailed)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := link.Save(); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UpperFirst(err.Error())})
|
||||
Abort(c, http.StatusConflict, i18n.ErrSaveFailed)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +83,7 @@ func DeleteLink(c *gin.Context) {
|
|||
link := entity.FindLink(clean.Token(c.Param("link")))
|
||||
|
||||
if err := link.Delete(); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UpperFirst(err.Error())})
|
||||
Abort(c, http.StatusConflict, i18n.ErrDeleteFailed)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -135,13 +134,13 @@ func CreateLink(c *gin.Context) {
|
|||
|
||||
if frm.Password != "" {
|
||||
if err := link.SetPassword(frm.Password); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UpperFirst(err.Error())})
|
||||
Abort(c, http.StatusConflict, i18n.ErrSaveFailed)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := link.Save(); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, gin.H{"error": txt.UpperFirst(err.Error())})
|
||||
Abort(c, http.StatusConflict, i18n.ErrSaveFailed)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ func RemovePhotoLabel(router *gin.RouterGroup) {
|
|||
labelId, err := strconv.Atoi(clean.Token(c.Param("id")))
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())})
|
||||
Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -175,7 +175,7 @@ func RemovePhotoLabel(router *gin.RouterGroup) {
|
|||
label, err := query.PhotoLabel(m.ID, uint(labelId))
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())})
|
||||
Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +255,7 @@ func UpdatePhotoLabel(router *gin.RouterGroup) {
|
|||
labelId, err := strconv.Atoi(clean.Token(c.Param("id")))
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())})
|
||||
Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -267,7 +267,7 @@ func UpdatePhotoLabel(router *gin.RouterGroup) {
|
|||
label, err := query.PhotoLabel(m.ID, uint(labelId))
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": txt.UpperFirst(err.Error())})
|
||||
Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ func TestRemovePhotoLabel(t *testing.T) {
|
|||
RemovePhotoLabel(router)
|
||||
r := PerformRequest(app, "DELETE", "/api/v1/photos/ps6sg6be2lvl0yh7/label/1000000")
|
||||
val := gjson.Get(r.Body.String(), "error")
|
||||
assert.Equal(t, "Record not found", val.String())
|
||||
assert.Equal(t, "Label not found", val.String())
|
||||
assert.Equal(t, http.StatusNotFound, r.Code)
|
||||
})
|
||||
t.Run("NotExistingPhoto", func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import (
|
|||
// @Param locale query string false "Locale for results (default: en)"
|
||||
// @Param count query int false "Maximum number of results (default: 10, max: 50)"
|
||||
// @Success 200 {object} places.SearchResults
|
||||
// @Failure 400 {object} gin.H "Missing search query"
|
||||
// @Failure 400 {object} i18n.Response
|
||||
// @Failure 401 {object} i18n.Response
|
||||
// @Failure 500 {object} gin.H "Search service error"
|
||||
// @Router /api/v1/places/search [get]
|
||||
|
|
@ -52,7 +52,7 @@ func GetPlacesSearch(router *gin.RouterGroup) {
|
|||
count := txt.IntVal(c.Query("count"), 1, 50, 10)
|
||||
|
||||
if query == "" {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Missing search query"})
|
||||
AbortBadRequest(c)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3435,8 +3435,15 @@
|
|||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"items": {},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
|
@ -11008,9 +11015,9 @@
|
|||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Missing search query",
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/gin.H"
|
||||
"$ref": "#/definitions/i18n.Response"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,17 @@ package i18n
|
|||
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.
|
||||
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"`
|
||||
}
|
||||
|
||||
func (r Response) String() string {
|
||||
|
|
@ -33,10 +39,14 @@ func (r Response) Success() bool {
|
|||
}
|
||||
|
||||
// NewResponse builds a Response with the given code, message ID, and optional parameters.
|
||||
// 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}
|
||||
if code < 400 {
|
||||
return Response{Code: code, Msg: Msg(id, params...)}
|
||||
r.Msg = Msg(id, params...)
|
||||
} else {
|
||||
return Response{Code: code, Err: Msg(id, params...)}
|
||||
r.Err = Msg(id, params...)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,35 +14,39 @@ 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)
|
||||
})
|
||||
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)
|
||||
})
|
||||
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)
|
||||
|
||||
if s, err := json.Marshal(resp); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
assert.Equal(t, `{"code":200,"message":"Changes successfully saved"}`, string(s))
|
||||
assert.Equal(t, `{"code":200,"message":"Changes successfully saved","id":"Changes successfully saved"}`, string(s))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResponse_String(t *testing.T) {
|
||||
t.Run("Error", func(t *testing.T) {
|
||||
resp := Response{404, "Not found", "page not found", "xyz"}
|
||||
resp := Response{Code: 404, Err: "Not found", Msg: "page not found", Details: "xyz"}
|
||||
assert.Equal(t, "Not found", resp.String())
|
||||
})
|
||||
t.Run("NoError", func(t *testing.T) {
|
||||
t.Run("Error", func(t *testing.T) {
|
||||
resp := Response{200, "", "Ok", "xyz"}
|
||||
resp := Response{Code: 200, Msg: "Ok", Details: "xyz"}
|
||||
assert.Equal(t, "Ok", resp.String())
|
||||
})
|
||||
})
|
||||
|
|
@ -50,12 +54,12 @@ func TestResponse_String(t *testing.T) {
|
|||
|
||||
func TestResponse_LowerString(t *testing.T) {
|
||||
t.Run("Error", func(t *testing.T) {
|
||||
resp := Response{404, "Not found", "page not found", "xyz"}
|
||||
resp := Response{Code: 404, Err: "Not found", Msg: "page not found", Details: "xyz"}
|
||||
assert.Equal(t, "not found", resp.LowerString())
|
||||
})
|
||||
t.Run("NoError", func(t *testing.T) {
|
||||
t.Run("Error", func(t *testing.T) {
|
||||
resp := Response{200, "", "Ok", "xyz"}
|
||||
resp := Response{Code: 200, Msg: "Ok", Details: "xyz"}
|
||||
assert.Equal(t, "ok", resp.LowerString())
|
||||
})
|
||||
})
|
||||
|
|
@ -63,12 +67,12 @@ func TestResponse_LowerString(t *testing.T) {
|
|||
|
||||
func TestResponse_Error(t *testing.T) {
|
||||
t.Run("Error", func(t *testing.T) {
|
||||
resp := Response{404, "Not found", "page not found", "xyz"}
|
||||
resp := Response{Code: 404, Err: "Not found", Msg: "page not found", Details: "xyz"}
|
||||
assert.Equal(t, "Not found", resp.Error())
|
||||
})
|
||||
t.Run("NoError", func(t *testing.T) {
|
||||
t.Run("Error", func(t *testing.T) {
|
||||
resp := Response{200, "", "Ok", "xyz"}
|
||||
resp := Response{Code: 200, Msg: "Ok", Details: "xyz"}
|
||||
assert.Equal(t, "", resp.Error())
|
||||
})
|
||||
})
|
||||
|
|
@ -76,12 +80,12 @@ func TestResponse_Error(t *testing.T) {
|
|||
|
||||
func TestResponse_Success(t *testing.T) {
|
||||
t.Run("Error", func(t *testing.T) {
|
||||
resp := Response{404, "Not found", "page not found", "xyz"}
|
||||
resp := Response{Code: 404, Err: "Not found", Msg: "page not found", Details: "xyz"}
|
||||
assert.Equal(t, false, resp.Success())
|
||||
})
|
||||
t.Run("NoError", func(t *testing.T) {
|
||||
t.Run("Error", func(t *testing.T) {
|
||||
resp := Response{200, "", "Ok", "xyz"}
|
||||
resp := Response{Code: 200, Msg: "Ok", Details: "xyz"}
|
||||
assert.Equal(t, true, resp.Success())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue