From b0d2cdc4a04a8a192fd150216e33b3b53b9544b3 Mon Sep 17 00:00:00 2001 From: aichy126 <16996097+aichy126@users.noreply.github.com> Date: Thu, 13 Apr 2023 11:17:17 +0800 Subject: [PATCH 01/57] add question operation --- docs/docs.go | 54 ++++++++++++++++++ docs/swagger.json | 54 ++++++++++++++++++ docs/swagger.yaml | 34 +++++++++++ internal/controller/question_controller.go | 39 +++++++++++++ internal/migrations/migrations.go | 1 + internal/migrations/v8.go | 56 +++++++++++++++++++ internal/router/answer_api_router.go | 1 + internal/schema/question_schema.go | 18 +++++- .../service/permission/permission_name.go | 2 + 9 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 internal/migrations/v8.go diff --git a/docs/docs.go b/docs/docs.go index 4622228b..73ff9ecd 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -3168,6 +3168,45 @@ const docTemplate = `{ } } }, + "/answer/api/v1/question/operation": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Operation question \\n operation [pin unpin hide show]", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Question" + ], + "summary": "Operation question", + "parameters": [ + { + "description": "question", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.OperationQuestionReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, "/answer/api/v1/question/page": { "get": { "description": "get questions by page", @@ -6739,6 +6778,21 @@ const docTemplate = `{ } } }, + "schema.OperationQuestionReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + }, + "operation": { + "description": "operation [pin unpin hide show]", + "type": "string" + } + } + }, "schema.PermissionMemberAction": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 240e543f..75078211 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -3156,6 +3156,45 @@ } } }, + "/answer/api/v1/question/operation": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Operation question \\n operation [pin unpin hide show]", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Question" + ], + "summary": "Operation question", + "parameters": [ + { + "description": "question", + "name": "data", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/schema.OperationQuestionReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.RespBody" + } + } + } + } + }, "/answer/api/v1/question/page": { "get": { "description": "get questions by page", @@ -6727,6 +6766,21 @@ } } }, + "schema.OperationQuestionReq": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + }, + "operation": { + "description": "operation [pin unpin hide show]", + "type": "string" + } + } + }, "schema.PermissionMemberAction": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 7aa79508..200abf16 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -983,6 +983,16 @@ definitions: description: inbox achievement type: string type: object + schema.OperationQuestionReq: + properties: + id: + type: string + operation: + description: operation [pin unpin hide show] + type: string + required: + - id + type: object schema.PermissionMemberAction: properties: action: @@ -3888,6 +3898,30 @@ paths: summary: get question details tags: - Question + /answer/api/v1/question/operation: + put: + consumes: + - application/json + description: Operation question \n operation [pin unpin hide show] + parameters: + - description: question + in: body + name: data + required: true + schema: + $ref: '#/definitions/schema.OperationQuestionReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.RespBody' + security: + - ApiKeyAuth: [] + summary: Operation question + tags: + - Question /answer/api/v1/question/page: get: consumes: diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go index cefa075a..5ab5f613 100644 --- a/internal/controller/question_controller.go +++ b/internal/controller/question_controller.go @@ -70,6 +70,45 @@ func (qc *QuestionController) RemoveQuestion(ctx *gin.Context) { handler.HandleResponse(ctx, err, nil) } +// OperationQuestion Operation question +// @Summary Operation question +// @Description Operation question \n operation [pin unpin hide show] +// @Tags Question +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param data body schema.OperationQuestionReq true "question" +// @Success 200 {object} handler.RespBody +// @Router /answer/api/v1/question/operation [put] +func (qc *QuestionController) OperationQuestion(ctx *gin.Context) { + req := &schema.OperationQuestionReq{} + if handler.BindAndCheck(ctx, req) { + return + } + req.ID = uid.DeShortID(req.ID) + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + canList, err := qc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{ + permission.QuestionPin, + permission.QuestionHide, + }) + if err != nil { + handler.HandleResponse(ctx, err, nil) + return + } + req.CanPin = canList[0] + req.CanList = canList[1] + if (req.Operation == schema.QuestionOperationPin || req.Operation == schema.QuestionOperationUnPin) && !req.CanPin { + handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + return + } + if (req.Operation == schema.QuestionOperationHide || req.Operation == schema.QuestionOperationShow) && !req.CanList { + handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) + return + } + + handler.HandleResponse(ctx, nil, nil) +} + // CloseQuestion Close question // @Summary Close question // @Description Close question diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 7b53293e..00ae9bc0 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -56,6 +56,7 @@ var migrations = []Migration{ NewMigration("add user role", addRoleFeatures, false), NewMigration("add theme and private mode", addThemeAndPrivateMode, true), NewMigration("add new answer notification", addNewAnswerNotification, true), + NewMigration("add user pin hide features", addRolePinAndHideFeatures, true), } // GetCurrentDBVersion returns the current db version diff --git a/internal/migrations/v8.go b/internal/migrations/v8.go new file mode 100644 index 00000000..325738d0 --- /dev/null +++ b/internal/migrations/v8.go @@ -0,0 +1,56 @@ +package migrations + +import ( + "github.com/answerdev/answer/internal/entity" + "github.com/answerdev/answer/internal/service/permission" + "xorm.io/xorm" +) + +func addRolePinAndHideFeatures(x *xorm.Engine) error { + + powers := []*entity.Power{ + {ID: 34, Name: "question pin", PowerType: permission.QuestionPin, Description: "Top or untop the question"}, + {ID: 35, Name: "question hide", PowerType: permission.QuestionHide, Description: "hide or show the question"}, + } + // insert default powers + for _, power := range powers { + exist, err := x.Get(&entity.Power{ID: power.ID}) + if err != nil { + return err + } + if exist { + _, err = x.ID(power.ID).Update(power) + } else { + _, err = x.Insert(power) + } + if err != nil { + return err + } + } + + rolePowerRels := []*entity.RolePowerRel{ + + {RoleID: 2, PowerType: permission.QuestionPin}, + {RoleID: 2, PowerType: permission.QuestionHide}, + + {RoleID: 3, PowerType: permission.QuestionPin}, + {RoleID: 3, PowerType: permission.QuestionHide}, + } + + // insert default powers + for _, rel := range rolePowerRels { + exist, err := x.Get(&entity.RolePowerRel{RoleID: rel.RoleID, PowerType: rel.PowerType}) + if err != nil { + return err + } + if exist { + continue + } + _, err = x.Insert(rel) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index 3ff5f873..3f79d2f2 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -190,6 +190,7 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { r.PUT("/question", a.questionController.UpdateQuestion) r.DELETE("/question", a.questionController.RemoveQuestion) r.PUT("/question/status", a.questionController.CloseQuestion) + r.PUT("/question/operation", a.questionController.OperationQuestion) r.PUT("/question/reopen", a.questionController.ReopenQuestion) r.GET("/question/similar", a.questionController.SearchByTitleLike) diff --git a/internal/schema/question_schema.go b/internal/schema/question_schema.go index 19a2c3fd..d9bbf76f 100644 --- a/internal/schema/question_schema.go +++ b/internal/schema/question_schema.go @@ -8,9 +8,13 @@ import ( ) const ( - SitemapMaxSize = 50000 - SitemapCachekey = "answer@sitemap" - SitemapPageCachekey = "answer@sitemap@page%d" + SitemapMaxSize = 50000 + SitemapCachekey = "answer@sitemap" + SitemapPageCachekey = "answer@sitemap@page%d" + QuestionOperationPin = "pin" + QuestionOperationUnPin = "unpin" + QuestionOperationHide = "hide" + QuestionOperationShow = "show" ) // RemoveQuestionReq delete question request @@ -28,6 +32,14 @@ type CloseQuestionReq struct { UserID string `json:"-"` // user_id } +type OperationQuestionReq struct { + ID string `validate:"required" json:"id"` + Operation string `json:"operation"` // operation [pin unpin hide show] + UserID string `json:"-"` // user_id + CanPin bool `json:"-"` + CanList bool `json:"-"` +} + type CloseQuestionMeta struct { CloseType int `json:"close_type"` CloseMsg string `json:"close_msg"` diff --git a/internal/service/permission/permission_name.go b/internal/service/permission/permission_name.go index 4a62ec86..29509f84 100644 --- a/internal/service/permission/permission_name.go +++ b/internal/service/permission/permission_name.go @@ -10,6 +10,8 @@ const ( QuestionReopen = "question.reopen" QuestionVoteUp = "question.vote_up" QuestionVoteDown = "question.vote_down" + QuestionPin = "question.pin" //Top or untop the question + QuestionHide = "question.hide" //hide or show the question AnswerAdd = "answer.add" AnswerEdit = "answer.edit" AnswerEditWithoutReview = "answer.edit_without_review" From 990e7497431a0dfc03033550087db935f569e7a8 Mon Sep 17 00:00:00 2001 From: aichy126 <16996097+aichy126@users.noreply.github.com> Date: Thu, 13 Apr 2023 14:39:27 +0800 Subject: [PATCH 02/57] add question actions --- internal/controller/question_controller.go | 4 ++++ internal/schema/question_schema.go | 4 ++++ internal/service/permission/permission_name.go | 2 ++ .../service/permission/question_permission.go | 16 +++++++++++++++- internal/service/question_service.go | 2 +- 5 files changed, 26 insertions(+), 2 deletions(-) diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go index 5ab5f613..97052183 100644 --- a/internal/controller/question_controller.go +++ b/internal/controller/question_controller.go @@ -191,6 +191,8 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) { permission.QuestionDelete, permission.QuestionClose, permission.QuestionReopen, + permission.QuestionPin, + permission.QuestionHide, }) if err != nil { handler.HandleResponse(ctx, err, nil) @@ -202,6 +204,8 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) { req.CanDelete = canList[1] req.CanClose = canList[2] req.CanReopen = canList[3] + req.CanPin = canList[4] + req.CanHide = canList[5] info, err := qc.questionService.GetQuestionAndAddPV(ctx, id, userID, req) if err != nil { diff --git a/internal/schema/question_schema.go b/internal/schema/question_schema.go index d9bbf76f..a051ab83 100644 --- a/internal/schema/question_schema.go +++ b/internal/schema/question_schema.go @@ -113,6 +113,10 @@ type QuestionPermission struct { CanClose bool `json:"-"` // whether user can reopen it CanReopen bool `json:"-"` + // whether user can pin it + CanPin bool `json:"-"` + // whether user can hide it + CanHide bool `json:"-"` // whether user can use reserved it CanUseReservedTag bool `json:"-"` } diff --git a/internal/service/permission/permission_name.go b/internal/service/permission/permission_name.go index 29509f84..783e3b49 100644 --- a/internal/service/permission/permission_name.go +++ b/internal/service/permission/permission_name.go @@ -45,4 +45,6 @@ const ( deleteActionName = "action.delete" closeActionName = "action.close" reopenActionName = "action.reopen" + pinActionName = "action.pin" + hideActionName = "action.hide" ) diff --git a/internal/service/permission/question_permission.go b/internal/service/permission/question_permission.go index 1321af45..a295f7ff 100644 --- a/internal/service/permission/question_permission.go +++ b/internal/service/permission/question_permission.go @@ -10,7 +10,7 @@ import ( // GetQuestionPermission get question permission func GetQuestionPermission(ctx context.Context, userID string, creatorUserID string, - canEdit, canDelete, canClose, canReopen bool) ( + canEdit, canDelete, canClose, canReopen, canPin, canHide bool) ( actions []*schema.PermissionMemberAction) { lang := handler.GetLangByCtx(ctx) actions = make([]*schema.PermissionMemberAction, 0) @@ -42,6 +42,20 @@ func GetQuestionPermission(ctx context.Context, userID string, creatorUserID str Type: "confirm", }) } + if canPin { + actions = append(actions, &schema.PermissionMemberAction{ + Action: "pin", + Name: translator.Tr(lang, pinActionName), + Type: "confirm", + }) + } + if canHide { + actions = append(actions, &schema.PermissionMemberAction{ + Action: "hide", + Name: translator.Tr(lang, hideActionName), + Type: "confirm", + }) + } if canDelete || userID == creatorUserID { actions = append(actions, &schema.PermissionMemberAction{ Action: "delete", diff --git a/internal/service/question_service.go b/internal/service/question_service.go index 5c4b2f3f..f3f8d546 100644 --- a/internal/service/question_service.go +++ b/internal/service/question_service.go @@ -641,7 +641,7 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s question.Description = htmltext.FetchExcerpt(question.HTML, "...", 240) question.MemberActions = permission.GetQuestionPermission(ctx, userID, question.UserID, - per.CanEdit, per.CanDelete, per.CanClose, per.CanReopen) + per.CanEdit, per.CanDelete, per.CanClose, per.CanReopen, per.CanPin, per.CanHide) return question, nil } From 13931e8da17a9f8ef8bc69d9f43166a8a57aa74e Mon Sep 17 00:00:00 2001 From: aichy126 <16996097+aichy126@users.noreply.github.com> Date: Thu, 13 Apr 2023 14:49:38 +0800 Subject: [PATCH 03/57] action i18n --- i18n/en_US.yaml | 4 ++++ i18n/zh_CN.yaml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 3decada5..2171e55a 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -23,6 +23,10 @@ backend: other: Close reopen: other: Reopen + pin: + other: pin + hide: + other: hide role: name: user: diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index 3bfdef38..b47ced41 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -22,6 +22,10 @@ backend: other: 关闭 reopen: other: 重新打开 + pin: + other: 置顶 + hide: + other: 隐藏 role: name: user: From a038664fb403f97b76c81ef7b79cf9cc48460550 Mon Sep 17 00:00:00 2001 From: aichy126 <16996097+aichy126@users.noreply.github.com> Date: Thu, 13 Apr 2023 15:46:36 +0800 Subject: [PATCH 04/57] update question pin and show --- docs/docs.go | 8 ++++++ docs/swagger.json | 8 ++++++ docs/swagger.yaml | 6 ++++ internal/entity/question_entity.go | 6 ++++ internal/migrations/v8.go | 29 ++++++++++++++++++++ internal/repo/question/question_repo.go | 13 +++++---- internal/schema/question_schema.go | 2 ++ internal/service/question_common/question.go | 2 ++ 8 files changed, 69 insertions(+), 5 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 73ff9ecd..f0954b62 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -6948,6 +6948,14 @@ const docTemplate = `{ "operator": { "$ref": "#/definitions/schema.QuestionPageRespOperator" }, + "pin": { + "description": "1: unpin, 2: pin", + "type": "integer" + }, + "show": { + "description": "0: show, 1: hide", + "type": "integer" + }, "status": { "type": "integer" }, diff --git a/docs/swagger.json b/docs/swagger.json index 75078211..124e77ba 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -6936,6 +6936,14 @@ "operator": { "$ref": "#/definitions/schema.QuestionPageRespOperator" }, + "pin": { + "description": "1: unpin, 2: pin", + "type": "integer" + }, + "show": { + "description": "0: show, 1: hide", + "type": "integer" + }, "status": { "type": "integer" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 200abf16..902d037f 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1105,6 +1105,12 @@ definitions: type: string operator: $ref: '#/definitions/schema.QuestionPageRespOperator' + pin: + description: '1: unpin, 2: pin' + type: integer + show: + description: '0: show, 1: hide' + type: integer status: type: integer tags: diff --git a/internal/entity/question_entity.go b/internal/entity/question_entity.go index 4b4f4923..41c9236b 100644 --- a/internal/entity/question_entity.go +++ b/internal/entity/question_entity.go @@ -8,6 +8,10 @@ const ( QuestionStatusAvailable = 1 QuestionStatusClosed = 2 QuestionStatusDeleted = 10 + QuestionUnPin = 1 + QuestionPin = 2 + QuestionShow = 1 + QuestionHide = 2 ) var AdminQuestionSearchStatus = map[string]int{ @@ -32,6 +36,8 @@ type Question struct { Title string `xorm:"not null default '' VARCHAR(150) title"` OriginalText string `xorm:"not null MEDIUMTEXT original_text"` ParsedText string `xorm:"not null MEDIUMTEXT parsed_text"` + Pin int `xorm:"not null default 1 INT(11) pin"` + Show int `xorm:"not null default 1 INT(11) show"` Status int `xorm:"not null default 1 INT(11) status"` ViewCount int `xorm:"not null default 0 INT(11) view_count"` UniqueViewCount int `xorm:"not null default 0 INT(11) unique_view_count"` diff --git a/internal/migrations/v8.go b/internal/migrations/v8.go index 325738d0..dd46a558 100644 --- a/internal/migrations/v8.go +++ b/internal/migrations/v8.go @@ -1,6 +1,8 @@ package migrations import ( + "time" + "github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/internal/service/permission" "xorm.io/xorm" @@ -51,6 +53,33 @@ func addRolePinAndHideFeatures(x *xorm.Engine) error { return err } } + type Question struct { + ID string `xorm:"not null pk BIGINT(20) id"` + CreatedAt time.Time `xorm:"not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated_at TIMESTAMP"` + UserID string `xorm:"not null default 0 BIGINT(20) INDEX user_id"` + LastEditUserID string `xorm:"not null default 0 BIGINT(20) last_edit_user_id"` + Title string `xorm:"not null default '' VARCHAR(150) title"` + OriginalText string `xorm:"not null MEDIUMTEXT original_text"` + ParsedText string `xorm:"not null MEDIUMTEXT parsed_text"` + Status int `xorm:"not null default 1 INT(11) status"` + Pin int `xorm:"not null default 1 INT(11) pin"` + Show int `xorm:"not null default 1 INT(11) show"` + ViewCount int `xorm:"not null default 0 INT(11) view_count"` + UniqueViewCount int `xorm:"not null default 0 INT(11) unique_view_count"` + VoteCount int `xorm:"not null default 0 INT(11) vote_count"` + AnswerCount int `xorm:"not null default 0 INT(11) answer_count"` + CollectionCount int `xorm:"not null default 0 INT(11) collection_count"` + FollowCount int `xorm:"not null default 0 INT(11) follow_count"` + AcceptedAnswerID string `xorm:"not null default 0 BIGINT(20) accepted_answer_id"` + LastAnswerID string `xorm:"not null default 0 BIGINT(20) last_answer_id"` + PostUpdateTime time.Time `xorm:"post_update_time TIMESTAMP"` + RevisionID string `xorm:"not null default 0 BIGINT(20) revision_id"` + } + err := x.Sync(new(Question)) + if err != nil { + return err + } return nil } diff --git a/internal/repo/question/question_repo.go b/internal/repo/question/question_repo.go index 5b1ab705..5c05dd19 100644 --- a/internal/repo/question/question_repo.go +++ b/internal/repo/question/question_repo.go @@ -258,19 +258,22 @@ func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, } if len(userID) > 0 { session.And("question.user_id = ?", userID) + } else { + session.And("question.show = ?", entity.QuestionShow) } + switch orderCond { case "newest": - session.OrderBy("question.created_at DESC") + session.OrderBy("question.pin desc,question.created_at DESC") case "active": - session.OrderBy("question.post_update_time DESC, question.updated_at DESC") + session.OrderBy("question.pin desc,question.post_update_time DESC, question.updated_at DESC") case "frequent": - session.OrderBy("question.view_count DESC") + session.OrderBy("question.pin desc,question.view_count DESC") case "score": - session.OrderBy("question.vote_count DESC, question.view_count DESC") + session.OrderBy("question.pin desc,question.vote_count DESC, question.view_count DESC") case "unanswered": session.Where("question.last_answer_id = 0") - session.OrderBy("question.created_at DESC") + session.OrderBy("question.pin desc,question.created_at DESC") } total, err = pager.Help(page, pageSize, &questionList, &entity.Question{}, session) diff --git a/internal/schema/question_schema.go b/internal/schema/question_schema.go index a051ab83..b5b523e8 100644 --- a/internal/schema/question_schema.go +++ b/internal/schema/question_schema.go @@ -311,6 +311,8 @@ type QuestionPageResp struct { Title string `json:"title"` UrlTitle string `json:"url_title"` Description string `json:"description"` + Pin int `json:"pin"` // 1: unpin, 2: pin + Show int `json:"show"` // 0: show, 1: hide Status int `json:"status"` Tags []*TagResp `json:"tags"` diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index a0d039fc..4b4f259b 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -271,6 +271,8 @@ func (qs *QuestionCommon) FormatQuestionsPage( FollowCount: questionInfo.FollowCount, AcceptedAnswerID: questionInfo.AcceptedAnswerID, LastAnswerID: questionInfo.LastAnswerID, + Pin: questionInfo.Pin, + Show: questionInfo.Show, } questionIDs = append(questionIDs, questionInfo.ID) From 766bf793c464ac291382011c5a04d14bc7889317 Mon Sep 17 00:00:00 2001 From: aichy126 <16996097+aichy126@users.noreply.github.com> Date: Thu, 13 Apr 2023 16:59:11 +0800 Subject: [PATCH 05/57] update question pin show --- internal/schema/question_schema.go | 2 ++ internal/service/question_common/question.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/internal/schema/question_schema.go b/internal/schema/question_schema.go index b5b523e8..9284ba9c 100644 --- a/internal/schema/question_schema.go +++ b/internal/schema/question_schema.go @@ -184,6 +184,8 @@ type QuestionInfo struct { UpdateTime int64 `json:"-"` // update_time PostUpdateTime int64 `json:"update_time"` QuestionUpdateTime int64 `json:"edit_time"` + Pin int `json:"pin"` // 1: unpin, 2: pin + Show int `json:"show"` // 0: show, 1: hide Status int `json:"status"` Operation *Operation `json:"operation,omitempty"` UserID string `json:"-" ` diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index 4b4f259b..38ae8a9d 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -528,6 +528,8 @@ func (qs *QuestionCommon) ShowFormat(ctx context.Context, data *entity.Question) info.QuestionUpdateTime = 0 } info.Status = data.Status + info.Pin = data.Pin + info.Show = data.Show info.UserID = data.UserID info.LastEditUserID = data.LastEditUserID if data.LastAnswerID != "0" { From 0de09c53050f830f3880e48b5cd43a041b5c050a Mon Sep 17 00:00:00 2001 From: aichy126 <16996097+aichy126@users.noreply.github.com> Date: Thu, 13 Apr 2023 17:34:02 +0800 Subject: [PATCH 06/57] update question pin show --- internal/base/constant/acticity.go | 4 ++ internal/controller/question_controller.go | 4 +- internal/repo/question/question_repo.go | 9 ++++ internal/service/question_common/question.go | 1 + internal/service/question_service.go | 53 ++++++++++++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/internal/base/constant/acticity.go b/internal/base/constant/acticity.go index 8300147b..b0db1f46 100644 --- a/internal/base/constant/acticity.go +++ b/internal/base/constant/acticity.go @@ -29,6 +29,10 @@ const ( ActQuestionRollback ActivityTypeKey = "question.rollback" ActQuestionDeleted ActivityTypeKey = "question.deleted" ActQuestionUndeleted ActivityTypeKey = "question.undeleted" + ActQuestionPin ActivityTypeKey = "question.pin" + ActQuestionUnPin ActivityTypeKey = "question.unpin" + ActQuestionHide ActivityTypeKey = "question.hide" + ActQuestionShow ActivityTypeKey = "question.show" ) const ( diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go index 97052183..5c9d2e25 100644 --- a/internal/controller/question_controller.go +++ b/internal/controller/question_controller.go @@ -105,8 +105,8 @@ func (qc *QuestionController) OperationQuestion(ctx *gin.Context) { handler.HandleResponse(ctx, errors.Forbidden(reason.RankFailToMeetTheCondition), nil) return } - - handler.HandleResponse(ctx, nil, nil) + err = qc.questionService.OperationQuestion(ctx, req) + handler.HandleResponse(ctx, err, nil) } // CloseQuestion Close question diff --git a/internal/repo/question/question_repo.go b/internal/repo/question/question_repo.go index 5c05dd19..c22a97d6 100644 --- a/internal/repo/question/question_repo.go +++ b/internal/repo/question/question_repo.go @@ -125,6 +125,15 @@ func (qr *questionRepo) UpdateQuestionStatusWithOutUpdateTime(ctx context.Contex return nil } +func (qr *questionRepo) UpdateQuestionOperation(ctx context.Context, question *entity.Question) (err error) { + question.ID = uid.DeShortID(question.ID) + _, err = qr.data.DB.Where("id =?", question.ID).Cols("pin", "show").Update(question) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return nil +} + func (qr *questionRepo) UpdateAccepted(ctx context.Context, question *entity.Question) (err error) { question.ID = uid.DeShortID(question.ID) _, err = qr.data.DB.Where("id =?", question.ID).Cols("accepted_answer_id").Update(question) diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index 38ae8a9d..842670f4 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -36,6 +36,7 @@ type QuestionRepo interface { questionList []*entity.Question, total int64, err error) UpdateQuestionStatus(ctx context.Context, question *entity.Question) (err error) UpdateQuestionStatusWithOutUpdateTime(ctx context.Context, question *entity.Question) (err error) + UpdateQuestionOperation(ctx context.Context, question *entity.Question) (err error) SearchByTitleLike(ctx context.Context, title string) (questionList []*entity.Question, err error) UpdatePvCount(ctx context.Context, questionID string) (err error) UpdateAnswerCount(ctx context.Context, questionID string, num int) (err error) diff --git a/internal/service/question_service.go b/internal/service/question_service.go index f3f8d546..b59e97d5 100644 --- a/internal/service/question_service.go +++ b/internal/service/question_service.go @@ -319,6 +319,59 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question return } +// OperationQuestion +func (qs *QuestionService) OperationQuestion(ctx context.Context, req *schema.OperationQuestionReq) (err error) { + questionInfo, has, err := qs.questionRepo.GetQuestion(ctx, req.ID) + if err != nil { + return err + } + if !has { + return nil + } + // Hidden question cannot be placed at the top + if questionInfo.Show == entity.QuestionHide && req.Operation == schema.QuestionOperationPin { + return nil + } + // Question cannot be hidden when they are at the top + if questionInfo.Pin == entity.QuestionPin && req.Operation == schema.QuestionOperationHide { + return nil + } + + switch req.Operation { + case schema.QuestionOperationHide: + questionInfo.Show = entity.QuestionHide + case schema.QuestionOperationShow: + questionInfo.Show = entity.QuestionShow + case schema.QuestionOperationPin: + questionInfo.Pin = entity.QuestionPin + case schema.QuestionOperationUnPin: + questionInfo.Pin = entity.QuestionUnPin + } + + err = qs.questionRepo.UpdateQuestionOperation(ctx, questionInfo) + if err != nil { + return err + } + + actMap := make(map[string]constant.ActivityTypeKey) + actMap[schema.QuestionOperationPin] = constant.ActQuestionPin + actMap[schema.QuestionOperationUnPin] = constant.ActQuestionUnPin + actMap[schema.QuestionOperationHide] = constant.ActQuestionHide + actMap[schema.QuestionOperationShow] = constant.ActQuestionShow + + _, ok := actMap[req.Operation] + if !ok { + activity_queue.AddActivity(&schema.ActivityMsg{ + UserID: req.UserID, + ObjectID: questionInfo.ID, + OriginalObjectID: questionInfo.ID, + ActivityTypeKey: actMap[req.Operation], + }) + } + + return nil +} + // RemoveQuestion delete question func (qs *QuestionService) RemoveQuestion(ctx context.Context, req *schema.RemoveQuestionReq) (err error) { questionInfo, has, err := qs.questionRepo.GetQuestion(ctx, req.ID) From 0c174599655e037b62a1f9d64bac3a2161585e4d Mon Sep 17 00:00:00 2001 From: aichy126 <16996097+aichy126@users.noreply.github.com> Date: Thu, 13 Apr 2023 18:20:26 +0800 Subject: [PATCH 07/57] update question --- i18n/en_US.yaml | 4 ++ i18n/zh_CN.yaml | 4 ++ internal/base/constant/acticity.go | 4 ++ internal/controller/question_controller.go | 8 +++- internal/migrations/v8.go | 37 ++++++++++++++++++- internal/schema/question_schema.go | 4 +- .../service/permission/permission_name.go | 8 +++- .../service/permission/question_permission.go | 18 ++++++++- internal/service/question_service.go | 16 +++++++- 9 files changed, 95 insertions(+), 8 deletions(-) diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 2171e55a..333e75fe 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -27,6 +27,10 @@ backend: other: pin hide: other: hide + unpin: + other: unpin + show: + other: show role: name: user: diff --git a/i18n/zh_CN.yaml b/i18n/zh_CN.yaml index b47ced41..5fd46710 100644 --- a/i18n/zh_CN.yaml +++ b/i18n/zh_CN.yaml @@ -26,6 +26,10 @@ backend: other: 置顶 hide: other: 隐藏 + unpin: + other: 取消置顶 + show: + other: 显示 role: name: user: diff --git a/internal/base/constant/acticity.go b/internal/base/constant/acticity.go index b0db1f46..334d8e6d 100644 --- a/internal/base/constant/acticity.go +++ b/internal/base/constant/acticity.go @@ -14,6 +14,10 @@ const ( ActFollow = "follow" ActAccepted = "accepted" ActAccept = "accept" + ActPin = "pin" + ActUnPin = "unpin" + ActShow = "show" + ActHide = "hide" ) const ( diff --git a/internal/controller/question_controller.go b/internal/controller/question_controller.go index 5c9d2e25..6b474670 100644 --- a/internal/controller/question_controller.go +++ b/internal/controller/question_controller.go @@ -89,7 +89,9 @@ func (qc *QuestionController) OperationQuestion(ctx *gin.Context) { req.UserID = middleware.GetLoginUserIDFromContext(ctx) canList, err := qc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{ permission.QuestionPin, + permission.QuestionUnPin, permission.QuestionHide, + permission.QuestionShow, }) if err != nil { handler.HandleResponse(ctx, err, nil) @@ -192,7 +194,9 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) { permission.QuestionClose, permission.QuestionReopen, permission.QuestionPin, + permission.QuestionUnPin, permission.QuestionHide, + permission.QuestionShow, }) if err != nil { handler.HandleResponse(ctx, err, nil) @@ -205,7 +209,9 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) { req.CanClose = canList[2] req.CanReopen = canList[3] req.CanPin = canList[4] - req.CanHide = canList[5] + req.CanUnPin = canList[5] + req.CanHide = canList[6] + req.CanShow = canList[7] info, err := qc.questionService.GetQuestionAndAddPV(ctx, id, userID, req) if err != nil { diff --git a/internal/migrations/v8.go b/internal/migrations/v8.go index dd46a558..090651fa 100644 --- a/internal/migrations/v8.go +++ b/internal/migrations/v8.go @@ -1,18 +1,22 @@ package migrations import ( + "fmt" "time" "github.com/answerdev/answer/internal/entity" "github.com/answerdev/answer/internal/service/permission" + "github.com/segmentfault/pacman/log" "xorm.io/xorm" ) func addRolePinAndHideFeatures(x *xorm.Engine) error { powers := []*entity.Power{ - {ID: 34, Name: "question pin", PowerType: permission.QuestionPin, Description: "Top or untop the question"}, - {ID: 35, Name: "question hide", PowerType: permission.QuestionHide, Description: "hide or show the question"}, + {ID: 34, Name: "question pin", PowerType: permission.QuestionPin, Description: "top the question"}, + {ID: 35, Name: "question hide", PowerType: permission.QuestionHide, Description: "hide the question"}, + {ID: 36, Name: "question unpin", PowerType: permission.QuestionUnPin, Description: "untop the question"}, + {ID: 37, Name: "question show", PowerType: permission.QuestionShow, Description: "show the question"}, } // insert default powers for _, power := range powers { @@ -34,9 +38,13 @@ func addRolePinAndHideFeatures(x *xorm.Engine) error { {RoleID: 2, PowerType: permission.QuestionPin}, {RoleID: 2, PowerType: permission.QuestionHide}, + {RoleID: 2, PowerType: permission.QuestionUnPin}, + {RoleID: 2, PowerType: permission.QuestionShow}, {RoleID: 3, PowerType: permission.QuestionPin}, {RoleID: 3, PowerType: permission.QuestionHide}, + {RoleID: 3, PowerType: permission.QuestionUnPin}, + {RoleID: 3, PowerType: permission.QuestionShow}, } // insert default powers @@ -53,6 +61,31 @@ func addRolePinAndHideFeatures(x *xorm.Engine) error { return err } } + + defaultConfigTable := []*entity.Config{ + {ID: 119, Key: "question.pin", Value: `0`}, + {ID: 120, Key: "question.unpin", Value: `0`}, + {ID: 121, Key: "question.show", Value: `0`}, + {ID: 122, Key: "question.hide", Value: `0`}, + } + for _, c := range defaultConfigTable { + exist, err := x.Get(&entity.Config{ID: c.ID, Key: c.Key}) + if err != nil { + return fmt.Errorf("get config failed: %w", err) + } + if exist { + if _, err = x.Update(c, &entity.Config{ID: c.ID, Key: c.Key}); err != nil { + log.Errorf("update %+v config failed: %s", c, err) + return fmt.Errorf("update config failed: %w", err) + } + continue + } + if _, err = x.Insert(&entity.Config{ID: c.ID, Key: c.Key, Value: c.Value}); err != nil { + log.Errorf("insert %+v config failed: %s", c, err) + return fmt.Errorf("add config failed: %w", err) + } + } + type Question struct { ID string `xorm:"not null pk BIGINT(20) id"` CreatedAt time.Time `xorm:"not null default CURRENT_TIMESTAMP TIMESTAMP created_at"` diff --git a/internal/schema/question_schema.go b/internal/schema/question_schema.go index 9284ba9c..b86dcff9 100644 --- a/internal/schema/question_schema.go +++ b/internal/schema/question_schema.go @@ -114,9 +114,11 @@ type QuestionPermission struct { // whether user can reopen it CanReopen bool `json:"-"` // whether user can pin it - CanPin bool `json:"-"` + CanPin bool `json:"-"` + CanUnPin bool `json:"-"` // whether user can hide it CanHide bool `json:"-"` + CanShow bool `json:"-"` // whether user can use reserved it CanUseReservedTag bool `json:"-"` } diff --git a/internal/service/permission/permission_name.go b/internal/service/permission/permission_name.go index 783e3b49..1d49ce3f 100644 --- a/internal/service/permission/permission_name.go +++ b/internal/service/permission/permission_name.go @@ -10,8 +10,10 @@ const ( QuestionReopen = "question.reopen" QuestionVoteUp = "question.vote_up" QuestionVoteDown = "question.vote_down" - QuestionPin = "question.pin" //Top or untop the question - QuestionHide = "question.hide" //hide or show the question + QuestionPin = "question.pin" //Top the question + QuestionUnPin = "question.unpin" //untop the question + QuestionHide = "question.hide" //hide the question + QuestionShow = "question.show" //show the question AnswerAdd = "answer.add" AnswerEdit = "answer.edit" AnswerEditWithoutReview = "answer.edit_without_review" @@ -46,5 +48,7 @@ const ( closeActionName = "action.close" reopenActionName = "action.reopen" pinActionName = "action.pin" + unpinActionName = "action.unpin" hideActionName = "action.hide" + showActionName = "action.show" ) diff --git a/internal/service/permission/question_permission.go b/internal/service/permission/question_permission.go index a295f7ff..6f4d126f 100644 --- a/internal/service/permission/question_permission.go +++ b/internal/service/permission/question_permission.go @@ -10,7 +10,7 @@ import ( // GetQuestionPermission get question permission func GetQuestionPermission(ctx context.Context, userID string, creatorUserID string, - canEdit, canDelete, canClose, canReopen, canPin, canHide bool) ( + canEdit, canDelete, canClose, canReopen, canPin, canHide, CanUnPin, canShow bool) ( actions []*schema.PermissionMemberAction) { lang := handler.GetLangByCtx(ctx) actions = make([]*schema.PermissionMemberAction, 0) @@ -56,6 +56,22 @@ func GetQuestionPermission(ctx context.Context, userID string, creatorUserID str Type: "confirm", }) } + + if CanUnPin { + actions = append(actions, &schema.PermissionMemberAction{ + Action: "unpin", + Name: translator.Tr(lang, unpinActionName), + Type: "confirm", + }) + } + + if canShow { + actions = append(actions, &schema.PermissionMemberAction{ + Action: "show", + Name: translator.Tr(lang, showActionName), + Type: "confirm", + }) + } if canDelete || userID == creatorUserID { actions = append(actions, &schema.PermissionMemberAction{ Action: "delete", diff --git a/internal/service/question_service.go b/internal/service/question_service.go index b59e97d5..dfcdff3d 100644 --- a/internal/service/question_service.go +++ b/internal/service/question_service.go @@ -685,6 +685,20 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s if question.Status == entity.QuestionStatusClosed { per.CanClose = false } + if question.Pin == entity.QuestionPin { + per.CanPin = false + per.CanHide = false + } + if question.Pin == entity.QuestionUnPin { + per.CanUnPin = false + } + if question.Show == entity.QuestionShow { + per.CanShow = false + } + if question.Show == entity.QuestionHide { + per.CanHide = false + per.CanPin = false + } if question.Status == entity.QuestionStatusDeleted { operation := &schema.Operation{} operation.Msg = translator.Tr(handler.GetLangByCtx(ctx), reason.QuestionAlreadyDeleted) @@ -694,7 +708,7 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s question.Description = htmltext.FetchExcerpt(question.HTML, "...", 240) question.MemberActions = permission.GetQuestionPermission(ctx, userID, question.UserID, - per.CanEdit, per.CanDelete, per.CanClose, per.CanReopen, per.CanPin, per.CanHide) + per.CanEdit, per.CanDelete, per.CanClose, per.CanReopen, per.CanPin, per.CanHide, per.CanUnPin, per.CanShow) return question, nil } From 0124cfc435ad0d39343381fcc20468bd52164284 Mon Sep 17 00:00:00 2001 From: aichy126 <16996097+aichy126@users.noreply.github.com> Date: Thu, 13 Apr 2023 18:27:43 +0800 Subject: [PATCH 08/57] update question --- internal/service/question_service.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/service/question_service.go b/internal/service/question_service.go index dfcdff3d..c3271b6e 100644 --- a/internal/service/question_service.go +++ b/internal/service/question_service.go @@ -358,9 +358,8 @@ func (qs *QuestionService) OperationQuestion(ctx context.Context, req *schema.Op actMap[schema.QuestionOperationUnPin] = constant.ActQuestionUnPin actMap[schema.QuestionOperationHide] = constant.ActQuestionHide actMap[schema.QuestionOperationShow] = constant.ActQuestionShow - _, ok := actMap[req.Operation] - if !ok { + if ok { activity_queue.AddActivity(&schema.ActivityMsg{ UserID: req.UserID, ObjectID: questionInfo.ID, From 0c533e554dccbb5944c89adf79fc71ae1811db2f Mon Sep 17 00:00:00 2001 From: aichy126 <16996097+aichy126@users.noreply.github.com> Date: Thu, 13 Apr 2023 18:39:06 +0800 Subject: [PATCH 09/57] update question --- internal/service/question_service.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/service/question_service.go b/internal/service/question_service.go index c3271b6e..cea7af84 100644 --- a/internal/service/question_service.go +++ b/internal/service/question_service.go @@ -270,6 +270,8 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question question.Status = entity.QuestionStatusAvailable question.RevisionID = "0" question.CreatedAt = now + question.Pin = entity.QuestionUnPin + question.Show = entity.QuestionShow //question.UpdatedAt = nil err = qs.questionRepo.AddQuestion(ctx, question) if err != nil { @@ -698,6 +700,7 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s per.CanHide = false per.CanPin = false } + if question.Status == entity.QuestionStatusDeleted { operation := &schema.Operation{} operation.Msg = translator.Tr(handler.GetLangByCtx(ctx), reason.QuestionAlreadyDeleted) From 17b7e9c7b8537eda7e38715865b6902e9ec96739 Mon Sep 17 00:00:00 2001 From: shuai Date: Thu, 13 Apr 2023 18:43:48 +0800 Subject: [PATCH 10/57] feat: add top hide function --- i18n/en_US.yaml | 22 +++- ui/src/common/constants.ts | 4 + ui/src/common/interface.ts | 5 + .../Comment/components/Form/index.tsx | 2 +- .../Comment/components/Reply/index.tsx | 2 +- ui/src/components/Customize/index.tsx | 4 +- ui/src/components/Icon/index.tsx | 11 +- ui/src/components/Operate/index.tsx | 105 +++++++++++++++++- ui/src/components/QuestionList/index.tsx | 8 ++ ui/src/components/Share/index.tsx | 2 +- ui/src/components/TagSelector/index.tsx | 21 ++-- .../Detail/components/Question/index.tsx | 8 ++ ui/src/pages/Users/Settings/Profile/index.tsx | 2 +- ui/src/services/common.ts | 4 + 14 files changed, 172 insertions(+), 28 deletions(-) diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 3decada5..7c4d19d1 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -789,6 +789,7 @@ ui: btn: Add question answers: answers question_detail: + action: Action Asked: Asked asked: asked update: Modified @@ -827,13 +828,14 @@ ui: li1_2: Back up any statements you make with references or personal experience. header_2: But avoid ... li2_1: Asking for help, seeking clarification, or responding to other answers. - reopen: confirm_btn: Reopen title: Reopen this post content: Are you sure you want to reopen? - success: This post has been reopened - + pin: + title: Pin this post + content: Are you sure you wish to pinned globally? This post will appear at the top of all post lists. + confirm_btn: Pin delete: title: Delete this post question: >- @@ -847,7 +849,6 @@ ui: of accepted answers can result in your account being blocked from answering. Are you sure you wish to delete? other: Are you sure you wish to delete? - tip_question_deleted: This post has been deleted tip_answer_deleted: This answer has been deleted btns: confirm: Confirm @@ -863,6 +864,7 @@ ui: reject: Reject skip: Skip discard_draft: Discard draft + pinned: Pinned search: title: Search Results keywords: Keywords @@ -1434,6 +1436,10 @@ ui: closed: closed reopened: reopened created: created + pin: pinned + unpin: unpinned + show: listed + hide: unlisted title: "History for" tag_title: "Timeline for" show_votes: "Show votes" @@ -1459,5 +1465,9 @@ ui: draft: discard_confirm: Are you sure you want to discard your draft? messages: - post_deleted: This post has been deleted. - + post_deleted: This post has been deleted. + post_pin: This post has been pinned. + post_unpin: This post has been unpinned. + post_hide_list: This post has been hidden from list. + post_show_list: This post has been shown to list. + post_reopen: This post has been reopened. diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index b794ede3..705e74c1 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -594,6 +594,10 @@ export const TIMELINE_NORMAL_ACTIVITY_TYPE = [ 'upvote', 'reopened', 'closed', + 'pin', + 'unpin', + 'show', + 'hide', ]; export const SYSTEM_AVATAR_OPTIONS = [ diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 640efcaa..7d51a3c8 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -526,3 +526,8 @@ export interface User { display_name: string; avatar: string; } + +export interface QuestionOperationReq { + id: string; + operation: 'pin' | 'unpin' | 'hide' | 'show'; +} diff --git a/ui/src/components/Comment/components/Form/index.tsx b/ui/src/components/Comment/components/Form/index.tsx index 66e6596f..5ca0753e 100644 --- a/ui/src/components/Comment/components/Form/index.tsx +++ b/ui/src/components/Comment/components/Form/index.tsx @@ -51,7 +51,7 @@ const Index = ({ 'd-flex align-items-start flex-column flex-md-row', className, )}> -
+
{ {t('reply_to')} {userName}
-
+
{ } scriptList?.forEach((so) => { const script = document.createElement('script'); - script.text = so.text; + script.text = `(() => { + ${so.text} + })();`; for (let i = 0; i < so.attributes.length; i += 1) { const attr = so.attributes[i]; script.setAttribute(attr.name, attr.value); diff --git a/ui/src/components/Icon/index.tsx b/ui/src/components/Icon/index.tsx index b1951b0e..ba973873 100644 --- a/ui/src/components/Icon/index.tsx +++ b/ui/src/components/Icon/index.tsx @@ -8,15 +8,24 @@ interface IProps { name: string; className?: string; size?: string; + title?: string; onClick?: () => void; } -const Icon: FC = ({ type = 'br', name, className, size, onClick }) => { +const Icon: FC = ({ + type = 'br', + name, + className, + size, + onClick, + title = '', +}) => { return ( ); }; diff --git a/ui/src/components/Operate/index.tsx b/ui/src/components/Operate/index.tsx index 06e9377e..96e94b7a 100644 --- a/ui/src/components/Operate/index.tsx +++ b/ui/src/components/Operate/index.tsx @@ -1,19 +1,22 @@ import { memo, FC } from 'react'; -import { Button } from 'react-bootstrap'; +import { Button, Dropdown } from 'react-bootstrap'; import { Link, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Modal } from '@/components'; import { useReportModal, useToast } from '@/hooks'; +import { QuestionOperationReq } from '@/common/interface'; import Share from '../Share'; import { deleteQuestion, deleteAnswer, editCheck, reopenQuestion, + questionOpetation, } from '@/services'; import { tryNormalLogged } from '@/utils/guard'; import { floppyNavigation } from '@/utils'; +import { toastStore } from '@/stores'; interface IProps { type: 'answer' | 'question'; @@ -78,7 +81,7 @@ const Index: FC = ({ id: qid, }).then(() => { toast.onShow({ - msg: t('tip_question_deleted'), + msg: t('post_deleted', { keyPrefix: 'messages' }), variant: 'success', }); callback?.('delete_question'); @@ -134,7 +137,7 @@ const Index: FC = ({ question_id: qid, }).then(() => { toast.onShow({ - msg: t('success', { keyPrefix: 'question_detail.reopen' }), + msg: t('post_reopen', { keyPrefix: 'messages' }), variant: 'success', }); refreshQuestion(); @@ -143,6 +146,51 @@ const Index: FC = ({ }); }; + const handleCommon = async (params) => { + await questionOpetation(params); + let msg = ''; + if (params.operation === 'pin') { + msg = t('post_pin', { keyPrefix: 'messages' }); + } + if (params.operation === 'unpin') { + msg = t('post_unpin', { keyPrefix: 'messages' }); + } + if (params.operation === 'hide') { + msg = t('post_hide_list', { keyPrefix: 'messages' }); + } + if (params.operation === 'show') { + msg = t('post_show_list', { keyPrefix: 'messages' }); + } + toastStore.getState().show({ + msg, + variant: 'success', + }); + setTimeout(() => { + refreshQuestion(); + }, 100); + }; + + const handlOtherActions = (action) => { + const params: QuestionOperationReq = { + id: qid, + operation: action, + }; + + if (action === 'pin') { + Modal.confirm({ + title: t('title', { keyPrefix: 'question_detail.pin' }), + content: t('content', { keyPrefix: 'question_detail.pin' }), + cancelBtnVariant: 'link', + confirmText: t('confirm_btn', { keyPrefix: 'question_detail.pin' }), + onConfirm: () => { + handleCommon(params); + }, + }); + } else { + handleCommon(params); + } + }; + const handleAction = (action) => { if (!tryNormalLogged(true)) { return; @@ -162,8 +210,33 @@ const Index: FC = ({ if (action === 'reopen') { handleReopen(); } + + if ( + action === 'pin' || + action === 'unpin' || + action === 'hide' || + action === 'show' + ) { + handlOtherActions(action); + } }; + const firstAction = + memberActions?.filter( + (v) => + v.action === 'report' || v.action === 'edit' || v.action === 'delete', + ) || []; + const secondAction = + memberActions?.filter( + (v) => + v.action === 'close' || + v.action === 'reopen' || + v.action === 'pin' || + v.action === 'unpin' || + v.action === 'hide' || + v.action === 'show', + ) || []; + return (
= ({ title={title} slugTitle={slugTitle} /> - {memberActions?.map((item) => { + {firstAction?.map((item) => { if (item.action === 'edit') { return ( handleEdit(evt, editUrl)} style={{ lineHeight: '23px' }}> {item.name} @@ -190,12 +263,32 @@ const Index: FC = ({ ); })} + {secondAction.length > 0 && ( + + + {t('action', { keyPrefix: 'question_detail' })} + + + {secondAction.map((item) => { + return ( + handleAction(item.action)}> + {item.name} + + ); + })} + + + )}
); }; diff --git a/ui/src/components/QuestionList/index.tsx b/ui/src/components/QuestionList/index.tsx index 94377b18..26599c79 100644 --- a/ui/src/components/QuestionList/index.tsx +++ b/ui/src/components/QuestionList/index.tsx @@ -14,6 +14,7 @@ import { QueryGroup, QuestionListLoader, Counts, + Icon, } from '@/components'; const QuestionOrderKeys: Type.QuestionOrderBy[] = [ @@ -62,6 +63,13 @@ const QuestionList: FC = ({ source, data, isLoading = false }) => { key={li.id} className="bg-transparent py-3 px-0 border-start-0 border-end-0">
+ {li.pin === 2 && ( + + )} diff --git a/ui/src/components/Share/index.tsx b/ui/src/components/Share/index.tsx index 855b02cb..34c45fc3 100644 --- a/ui/src/components/Share/index.tsx +++ b/ui/src/components/Share/index.tsx @@ -71,7 +71,7 @@ const Index: FC = ({ type, qid, aid, title, slugTitle = '' }) => { setShow(true)} style={{ lineHeight: '23px' }}> {t('share.name')} diff --git a/ui/src/components/TagSelector/index.tsx b/ui/src/components/TagSelector/index.tsx index a8bb0a6c..01961ff5 100644 --- a/ui/src/components/TagSelector/index.tsx +++ b/ui/src/components/TagSelector/index.tsx @@ -38,7 +38,7 @@ const TagSelector: FC = ({ const [initialValue, setInitialValue] = useState([...value]); const [currentIndex, setCurrentIndex] = useState(0); const [repeatIndex, setRepeatIndex] = useState(-1); - const [tag, setTag] = useState(''); + const [searchValue, setSearchValue] = useState(''); const [tags, setTags] = useState(null); const { t } = useTranslation('translation', { keyPrefix: 'tag_selector' }); const [visibleMenu, setVisibleMenu] = useState(false); @@ -101,12 +101,12 @@ const TagSelector: FC = ({ const fetchTags = (str) => { queryTags(str).then((res) => { const tagArray: Type.Tag[] = filterTags(res || []); - setTags(tagArray); + setTags(tagArray?.length > 5 ? tagArray.slice(0, 5) : tagArray); }); }; useEffect(() => { - fetchTags(tag); + fetchTags(searchValue); }, [visibleMenu]); const handleClick = (val: Type.Tag) => { @@ -146,7 +146,7 @@ const TagSelector: FC = ({ const handleSearch = async (e: React.ChangeEvent) => { const searchStr = e.currentTarget.value.replace(';', ''); - setTag(searchStr); + setSearchValue(searchStr); fetchTags(searchStr); }; @@ -172,7 +172,7 @@ const TagSelector: FC = ({ e.preventDefault(); if (tags.length === 0) { - tagModal.onShow(tag); + tagModal.onShow(searchValue); return; } if (currentIndex <= tags.length - 1) { @@ -228,13 +228,14 @@ const TagSelector: FC = ({ )} - {showRequiredTagText && + {!searchValue && + showRequiredTagText && tags && tags.filter((v) => v.recommend)?.length > 0 && (
{t('tag_required_text')}
@@ -251,17 +252,17 @@ const TagSelector: FC = ({ ); })} - {tag && tags && tags.length === 0 && ( + {searchValue && tags && tags.length === 0 && ( {t('no_result')} )} - {!hiddenCreateBtn && tag && ( + {!hiddenCreateBtn && searchValue && ( diff --git a/ui/src/pages/Questions/Detail/components/Question/index.tsx b/ui/src/pages/Questions/Detail/components/Question/index.tsx index 94a42981..3cfb7de0 100644 --- a/ui/src/pages/Questions/Detail/components/Question/index.tsx +++ b/ui/src/pages/Questions/Detail/components/Question/index.tsx @@ -11,6 +11,7 @@ import { Comment, FormatTime, htmlRender, + Icon, } from '@/components'; import { formatCount, guard } from '@/utils'; import { following } from '@/services'; @@ -65,6 +66,13 @@ const Index: FC = ({ data, initPage, hasAnswer, isLogged }) => { return (

+ {data?.pin === 2 && ( + + )} { - + {t('avatar.label')}
{ export const saveQuestionWidthAnaser = (params: Type.QuestionWithAnswer) => { return request.post('/answer/api/v1/question/answer', params); }; + +export const questionOpetation = (params: Type.QuestionOperationReq) => { + return request.put('/answer/api/v1/question/operation', params); +}; From 285a44be1b546e99f1bba4e3c69e5da817ca5bb6 Mon Sep 17 00:00:00 2001 From: shuai Date: Mon, 17 Apr 2023 14:24:00 +0800 Subject: [PATCH 11/57] feat: add login fail page --- ui/.eslintrc.js | 1 + ui/src/assets/images/carousel-wecom-1.jpg | Bin 0 -> 25793 bytes ui/src/assets/images/carousel-wecom-2.jpg | Bin 0 -> 12439 bytes ui/src/assets/images/carousel-wecom-3.jpg | Bin 0 -> 37821 bytes ui/src/assets/images/carousel-wecom-4.jpg | Bin 0 -> 21035 bytes ui/src/assets/images/carousel-wecom-5.jpg | Bin 0 -> 21501 bytes ui/src/components/HttpErrorContent/index.tsx | 6 +- ui/src/pages/50X/index.tsx | 6 +- ui/src/pages/LoginFail/index.tsx | 57 +++++++++++++++++++ ui/src/router/routes.ts | 4 ++ 10 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 ui/src/assets/images/carousel-wecom-1.jpg create mode 100644 ui/src/assets/images/carousel-wecom-2.jpg create mode 100644 ui/src/assets/images/carousel-wecom-3.jpg create mode 100644 ui/src/assets/images/carousel-wecom-4.jpg create mode 100644 ui/src/assets/images/carousel-wecom-5.jpg create mode 100644 ui/src/pages/LoginFail/index.tsx diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js index 032c2103..f4bf831f 100644 --- a/ui/.eslintrc.js +++ b/ui/.eslintrc.js @@ -36,6 +36,7 @@ module.exports = { 'react/no-unescaped-entities': 'off', 'react/require-default-props': 'off', 'arrow-body-style': 'off', + "global-require": "off", 'react/prop-types': 0, 'react/no-danger': 'off', 'jsx-a11y/no-static-element-interactions': 'off', diff --git a/ui/src/assets/images/carousel-wecom-1.jpg b/ui/src/assets/images/carousel-wecom-1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4dac6292121e27f9fd8a79f03b24934fb52fb08b GIT binary patch literal 25793 zcmeFZ1ymhN(D%vw#JS+lA(&tnZq!i>djC`y#^xX916l@ag+ya6kq9W8R zQu0zl@_fP~f{#o-?5( zHK)25W4u}C?)3Sa#XVn`e%* z*xpIm-^oc>c4Z!xq4=iadE$b^c-9UUwS-Q@!NM(gMK_$Mr?6;+_rSAsg#gOTiD z`BZt85@CRg?QPNDF#{3-;@DUKaRzghXe3jPia+*mvHmXuLMcW1|3xK`*XNpZ5~bQm zpTB?sP~(U>A5U$MYDmTYW=cG#>vc(U=%B^6Q~%43NwctvE*8_&(nam{mu?vUui@}L zQ=yb9Cp}62$_wIv;}qG+RG57zZW;EpHSb^VY#TGvUO(kNK<>J`D_vAOfp|cL1zd!@ z(4Ulc#V72KL#^(PeYp82^tTG`r_4HB^<#1t$LJgss_=%?BW$}JOHGdg+UsFoixDC( z2tR$v5MPTzr^*KZKWRBRUeTYih2>geGyDrfD5X>4tSpSg4=_2eI4&z|GhDFvN$FdL z8FQJ~qD}k$8SsBHRtI=u{R;qGG)$%5 z?LI0)$|AGBK?304>@Vs2KLX0LdR;~V#7kVJ(z(ZLI0EkuO(Q@cDHBJw2moQS0y3BY zG>ps8b{{qX5Etf*mCQU+g4Tw;@L#(3LUIW{)7;KS2q~!IYfgo&5CAf{yYh`Tfq|bQh4pu;+8k;OBZ{W1^FR(2cgVa#5IBJ$xnnG})Bfg_ zsp6LHH;O+xC_Q)6%vqeC@pA)28mCt$;btcKvi944jWk?IiR+~$5zgKDF#q&x zQ%C#nwvG^nP<0tD&F6@nm&6V&J`&C18gvJu4crZyK5#@NkY*I7xQ1Rd3oe7tEQR0d zI->oTzT^2r-~?Z|)V&z$3evr7x(r3SF86+vf3WxAPdzRJ$RvaiMT7vGl0rodr!m7y z!VG16EYphHvYYPZg{$dh2sNxN_m2BEbne%ZFRvpwE{E>$#haRUnfD+~fc5LB&q65g zveTbC6`ykeT)y2T5GY*b(3C-hy>-EHlhfJgfF*$+ofV;>B9(V3fDji8r4$PgloU@) zWw>e{oDpNC#(Vx-Z%=7Y++!qOJL$e{j z(5$|E2x*661J7i?d!__&gaX@Fw~r13*zzB7>{bB)zWs*%-qs^TrN;+Z2jb;4QD4f# zeT4pChkaA=4(A2{8kgQRL+}BB`FgnNF848x4@|RGgdh+Y6C1O7@C5*+FFvF;Q1pm| zlDFg}Sat?Dyd|{B2z+{s=HvCuRe*ue(Qe|!z4w_~6H-0Mgsekar`wB(*XfQ}I zWd)4b{2`U!GYLllW_98xRkqY{Di=`mV|oP*#xvr|I~nNW8FRvt^Q1IA!mz}|E>d5& zK&p^p6}c%_kfyuqCDd+{Umq+V5Zv^8WJr9Rl;vmXA7n|%&%#G?p6}qXgP=plr7Vk$ zky!EzP^lzqNv-%pUIs-xc7%Pli&l|Emvqyl3CcgkHF~0v}9Y8Al(5=CUEt~H`L)JHv ziT67nGLks|VSvo!cg5BE%^{dH$;keY6W*XdG$LgA^H@w-Ayuw5Y0) z%9FaNSLK9jX;J2aQv&r3I3ZJsNai7?M8LIeo0DBo*|h-xsz;O1uqr(kGr_F`sa%y2 z%|LdD5diE^I^%`mDhjK`eS&cQErUGT$q_}a0e$>wKkHt04qHmU%2J0gV>Cgk<(pg+ z&{`?S_z)r{txPB94hKU9mn1nIl?Tcyj6ZO?WuAqLn1X=Y-!0OYpWF{c1#v>S&|c+a za%&W^T0%SxUUZ0S|27F4Gvg1!22y=&(VS$i{a`nw-9QC!z`1DHBgDfDQaP`w zopws4z*Ye)&=^|7s*HifW&v8C(GXMH0H|uSJWYl;n0|{%h}gf+5|GM&S^l?Sy-4}h zFFdOY$-jv3*BSpi^RH&GareI<{*g_82SXpW{c3l5i}$aXzw$haFA7fW!YV+VdQxfn zMX(kCUJL@js=(n2=N*3vpFcpD;qxq+1dRrn)b^cs2&0GH?8=cE6@*B(0O|2w_4)Sw`E^D^V1I z1O6kWozexD}|LkDwO!4FSogeE-(`&(;|Czjz z*P7KAY+hm4fKkqnVS35&Jd@oA03NdD*cPDmeR}=k)d-hgj-bFL03Cl>bQ3dw`}4mg zbZ+$b1?uJ+e-`!G8Kbd~(to!^y=U`JjN6mqp1X_JK?ha(g_Wa%pnYfGx@Q>Q4FwC& z8NU~H+>wB~!AAdTnqR9SS)z+?`Y!t9b1~*7cP$FJeRJ!M|7P!c?WUhtw1Gax~&)|Q@M5zulM&pnq=I7wuObvjpi~#n#Pd{$&(2@aY z_lQ=iWq94qrK^+h|4KsW-b};vQdmyNTv)bYy|?*J33!Ycv_u~TrYs*+Lka-ulE|lY z{U>V7$VY&?Bl2Gq{d01Qfh29ny7jklhx+0a0a?P@d@cn33+MTr(|;#Ef5(LQ>hI;B z2h>(RZf)%G&-$C ziXSv3b3=a&4kY+~D!#-$jMyE8Y~D@>tdZOfo{LZEz&{W9&H$y!S!q&{IllnE031|V zQ|O~f;E5PIdGMp_pTAFHw@--t5V<#(65Ry+vnG%7!4<{T{-(q4!v16aB52R9WgyPN z8x&8N=okea6A!rWWxMe^Nu(ZphSVw1i8jQ4&JWN+jziMfNi}`pm>lu*qR0?|A3KFk zefJ^)af)2S{KZ$S(A&dqm2CgnXA)t+KZ{|C!AhlSViTG&6bEhb8xyGK!&FqR)x6sF zYdOiCGpVwL{s*i7JPN-C1tS0N<)5wm3&H1)`Qo3m-|v`@!SQ?fFI@N+#y_0?d+RfF z$Ws*sV*h{O@ducnlmr05`2TSU_j=m#UUu@!)?Sx8fn(nJ
-

{t('http_error', { code: httpCode })}

+ {showErroCode && ( +

{t('http_error', { code: httpCode })}

+ )}
{errMsg || t(`desc_${httpCode}`)}
diff --git a/ui/src/pages/50X/index.tsx b/ui/src/pages/50X/index.tsx index 0dcc49f9..ecf42ed4 100644 --- a/ui/src/pages/50X/index.tsx +++ b/ui/src/pages/50X/index.tsx @@ -1,7 +1,11 @@ +import { useSearchParams } from 'react-router-dom'; + import { HttpErrorContent } from '@/components'; const Index = () => { - return ; + const [searchParams] = useSearchParams(); + const msg = searchParams.get('msg') || ''; + return ; }; export default Index; diff --git a/ui/src/pages/LoginFail/index.tsx b/ui/src/pages/LoginFail/index.tsx new file mode 100644 index 00000000..bd009768 --- /dev/null +++ b/ui/src/pages/LoginFail/index.tsx @@ -0,0 +1,57 @@ +import { memo } from 'react'; +import { Container, Card, Col, Carousel } from 'react-bootstrap'; + +const data = [ + { + id: 1, + url: require('@/assets/images/carousel-wecom-1.jpg'), + }, + { + id: 2, + url: require('@/assets/images/carousel-wecom-2.jpg'), + }, + { + id: 3, + url: require('@/assets/images/carousel-wecom-3.jpg'), + }, + { + id: 4, + url: require('@/assets/images/carousel-wecom-4.jpg'), + }, + { + id: 5, + url: require('@/assets/images/carousel-wecom-5.jpg'), + }, +]; + +const Index = () => { + return ( + + + + +

WeCome Login

+

+ Login failed, please allow this app to access your email + information before try again. +

+ + + {data.map((item) => ( + + First slide + + ))} + +
+
+ +
+ ); +}; + +export default memo(Index); diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts index b776359f..0ae473c4 100644 --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@ -367,6 +367,10 @@ const routes: RouteNode[] = [ path: '50x', page: 'pages/50X', }, + { + path: 'login-fail', + page: 'pages/LoginFail', + }, ], }, { From 10afa0a47bb02b31641368ff85bc0ac70e7657f7 Mon Sep 17 00:00:00 2001 From: haitaoo Date: Mon, 17 Apr 2023 15:08:51 +0800 Subject: [PATCH 12/57] refactor: AccordionNav, SchemaForm --- i18n/en_US.yaml | 14 +- ui/src/common/constants.ts | 6 +- ui/src/common/interface.ts | 1 - ui/src/components/AccordionNav/index.tsx | 31 +- .../SchemaForm/components/Check.tsx | 58 ++ .../SchemaForm/components/Input.tsx | 45 ++ .../SchemaForm/components/Legend.tsx | 11 + .../SchemaForm/components/Select.tsx | 50 ++ .../SchemaForm/components/Switch.tsx | 46 ++ .../SchemaForm/components/Textarea.tsx | 50 ++ .../SchemaForm/components/Timezone.tsx | 33 ++ .../SchemaForm/components/Upload.tsx | 48 ++ .../components/SchemaForm/components/index.ts | 10 + ui/src/components/SchemaForm/index.tsx | 532 +++++++++++------- ui/src/pages/Admin/Interface/index.tsx | 21 +- ui/src/pages/Admin/Privileges/index.tsx | 177 ++++++ ui/src/pages/Admin/SettingsUsers/index.tsx | 110 ++++ ui/src/pages/Admin/index.tsx | 4 +- ui/src/router/routes.ts | 10 +- ui/src/services/admin/settings.ts | 18 + 20 files changed, 1025 insertions(+), 250 deletions(-) create mode 100644 ui/src/components/SchemaForm/components/Check.tsx create mode 100644 ui/src/components/SchemaForm/components/Input.tsx create mode 100644 ui/src/components/SchemaForm/components/Legend.tsx create mode 100644 ui/src/components/SchemaForm/components/Select.tsx create mode 100644 ui/src/components/SchemaForm/components/Switch.tsx create mode 100644 ui/src/components/SchemaForm/components/Textarea.tsx create mode 100644 ui/src/components/SchemaForm/components/Timezone.tsx create mode 100644 ui/src/components/SchemaForm/components/Upload.tsx create mode 100644 ui/src/components/SchemaForm/components/index.ts create mode 100644 ui/src/pages/Admin/Privileges/index.tsx create mode 100644 ui/src/pages/Admin/SettingsUsers/index.tsx diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index f71662ae..3b7f445a 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -1104,8 +1104,9 @@ ui: seo: SEO customize: Customize themes: Themes - css-html: CSS/HTML + css_html: CSS/HTML login: Login + privileges: Privileges plugins: Plugins installed_plugins: Installed Plugins website_welcome: Welcome to {{site_name}} @@ -1308,9 +1309,6 @@ ui: label: Timezone msg: Timezone cannot be empty. text: Choose a city in the same timezone as you. - avatar: - label: Default Avatar - text: For users without a custom avatar of their own. smtp: page_title: SMTP from_email: @@ -1454,7 +1452,13 @@ ui: deactivate: Deactivate activate: Activate settings: Settings - + settings_users: + title: Users + avatar: + label: Default Avatar + text: For users without a custom avatar of their own. + profile_editable: + title: Profile Editable form: optional: (optional) diff --git a/ui/src/common/constants.ts b/ui/src/common/constants.ts index 7a540ac9..619f019b 100644 --- a/ui/src/common/constants.ts +++ b/ui/src/common/constants.ts @@ -74,7 +74,8 @@ export const ADMIN_NAV_MENUS = [ name: 'themes', }, { - name: 'css-html', + name: 'css_html', + path: 'css-html', }, ], }, @@ -89,6 +90,8 @@ export const ADMIN_NAV_MENUS = [ { name: 'write' }, { name: 'seo' }, { name: 'login' }, + { name: 'users', path: 'settings-users' }, + { name: 'privileges' }, ], }, { @@ -96,6 +99,7 @@ export const ADMIN_NAV_MENUS = [ children: [ { name: 'installed_plugins', + path: 'installed-plugins', }, ], }, diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 8fc79f91..0f134e89 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -312,7 +312,6 @@ export interface HelmetUpdate extends Omit { export interface AdminSettingsInterface { language: string; time_zone?: string; - default_avatar?: string; } export interface AdminSettingsSmtp { diff --git a/ui/src/components/AccordionNav/index.tsx b/ui/src/components/AccordionNav/index.tsx index 472a3e63..4e64611f 100644 --- a/ui/src/components/AccordionNav/index.tsx +++ b/ui/src/components/AccordionNav/index.tsx @@ -18,12 +18,12 @@ function MenuNode({ }) { const { t } = useTranslation('translation', { keyPrefix: 'nav_menus' }); const isLeaf = !menu.children.length; - const href = isLeaf ? `${path}${menu.name}` : '#'; + const href = isLeaf ? `${path}${menu.path}` : '#'; return ( - + { callback(evt, menu, href, isLeaf); @@ -31,7 +31,7 @@ function MenuNode({ href={href} className={classNames( 'text-nowrap d-flex flex-nowrap align-items-center w-100', - { expanding, 'link-dark': activeKey !== menu.name }, + { expanding, 'link-dark': activeKey !== menu.path }, )}> {menu.displayName ? menu.displayName : t(menu.name)} @@ -44,7 +44,7 @@ function MenuNode({ )} {menu.children.length ? ( - + <> {menu.children.map((leaf) => { return ( @@ -53,7 +53,7 @@ function MenuNode({ callback={callback} activeKey={activeKey} path={path} - key={leaf.name} + key={leaf.path} /> ); })} @@ -73,17 +73,24 @@ const AccordionNav: FC = ({ menus = [], path = '/' }) => { const pathMatch = useMatch(`${path}*`); // auto set menu fields menus.forEach((m) => { + if (!m.path) { + m.path = m.name; + } if (!Array.isArray(m.children)) { m.children = []; } m.children.forEach((sm) => { + if (!sm.path) { + sm.path = sm.name; + } if (!Array.isArray(sm.children)) { sm.children = []; } }); }); + const splat = pathMatch && pathMatch.params['*']; - let activeKey = menus[0].name; + let activeKey = menus[0].path; if (splat) { activeKey = splat; } @@ -92,10 +99,10 @@ const AccordionNav: FC = ({ menus = [], path = '/' }) => { menus.forEach((li) => { if (li.children.length) { const matchedChild = li.children.find((el) => { - return el.name === activeKey; + return el.path === activeKey; }); if (matchedChild) { - openKey = li.name; + openKey = li.path; } } }); @@ -111,7 +118,7 @@ const AccordionNav: FC = ({ menus = [], path = '/' }) => { navigate(href); } } else { - setOpenKey(openKey === menu.name ? '' : menu.name); + setOpenKey(openKey === menu.path ? '' : menu.path); } }; useEffect(() => { @@ -127,8 +134,8 @@ const AccordionNav: FC = ({ menus = [], path = '/' }) => { path={path} callback={menuClick} activeKey={activeKey} - expanding={openKey === li.name} - key={li.name} + expanding={openKey === li.path} + key={li.path} /> ); })} diff --git a/ui/src/components/SchemaForm/components/Check.tsx b/ui/src/components/SchemaForm/components/Check.tsx new file mode 100644 index 00000000..5dc398bb --- /dev/null +++ b/ui/src/components/SchemaForm/components/Check.tsx @@ -0,0 +1,58 @@ +import React, { FC } from 'react'; +import { Form, Stack } from 'react-bootstrap'; + +import type * as Type from '@/common/interface'; + +interface Props { + type: 'radio' | 'checkbox'; + title: string; + desc: string | undefined; + fieldName: string; + onChange: (evt: React.ChangeEvent, ...rest) => void; + enumValues: (string | boolean | number)[]; + enumNames: string[]; + formData: Type.FormDataType; +} +const Index: FC = ({ + type = 'radio', + title, + desc, + fieldName, + onChange, + enumValues, + enumNames, + formData, +}) => { + const fieldObject = formData[fieldName]; + return ( + <> + {title} + + {enumValues?.map((item, index) => { + return ( + onChange(evt, index)} + /> + ); + })} + + + {fieldObject?.errorMsg} + + {desc ? {desc} : null} + + ); +}; + +export default Index; diff --git a/ui/src/components/SchemaForm/components/Input.tsx b/ui/src/components/SchemaForm/components/Input.tsx new file mode 100644 index 00000000..7d8d7e13 --- /dev/null +++ b/ui/src/components/SchemaForm/components/Input.tsx @@ -0,0 +1,45 @@ +import React, { FC } from 'react'; +import { Form } from 'react-bootstrap'; + +import type * as Type from '@/common/interface'; + +interface Props { + title: string; + desc: string | undefined; + type: string | undefined; + placeholder: string | undefined; + fieldName: string; + onChange: (evt: React.ChangeEvent, ...rest) => void; + formData: Type.FormDataType; +} +const Index: FC = ({ + title, + type = 'text', + desc, + placeholder = '', + fieldName, + onChange, + formData, +}) => { + const fieldObject = formData[fieldName]; + return ( + <> + {title} + + + {fieldObject?.errorMsg} + + {desc ? {desc} : null} + + ); +}; + +export default Index; diff --git a/ui/src/components/SchemaForm/components/Legend.tsx b/ui/src/components/SchemaForm/components/Legend.tsx new file mode 100644 index 00000000..96b58cf7 --- /dev/null +++ b/ui/src/components/SchemaForm/components/Legend.tsx @@ -0,0 +1,11 @@ +import { FC } from 'react'; +import { Form } from 'react-bootstrap'; + +interface Props { + title: string; +} +const Index: FC = ({ title }) => { + return {title}; +}; + +export default Index; diff --git a/ui/src/components/SchemaForm/components/Select.tsx b/ui/src/components/SchemaForm/components/Select.tsx new file mode 100644 index 00000000..27d6fff8 --- /dev/null +++ b/ui/src/components/SchemaForm/components/Select.tsx @@ -0,0 +1,50 @@ +import React, { FC } from 'react'; +import { Form } from 'react-bootstrap'; + +import type * as Type from '@/common/interface'; + +interface Props { + title: string; + desc: string | undefined; + fieldName: string; + onChange: (evt: React.ChangeEvent) => void; + enumValues: (string | boolean | number)[]; + enumNames: string[]; + formData: Type.FormDataType; +} +const Index: FC = ({ + title, + desc, + fieldName, + onChange, + enumValues, + enumNames, + formData, +}) => { + const fieldObject = formData[fieldName]; + return ( + <> + {title} + + {enumValues?.map((item, index) => { + return ( + + ); + })} + + + {fieldObject?.errorMsg} + + {desc ? {desc} : null} + + ); +}; + +export default Index; diff --git a/ui/src/components/SchemaForm/components/Switch.tsx b/ui/src/components/SchemaForm/components/Switch.tsx new file mode 100644 index 00000000..ab319425 --- /dev/null +++ b/ui/src/components/SchemaForm/components/Switch.tsx @@ -0,0 +1,46 @@ +import React, { FC } from 'react'; +import { Form } from 'react-bootstrap'; + +import type * as Type from '@/common/interface'; + +interface Props { + title: string; + desc: string | undefined; + label: string | undefined; + fieldName: string; + onChange: (evt: React.ChangeEvent, ...rest) => void; + formData: Type.FormDataType; +} +const Index: FC = ({ + title, + desc, + fieldName, + onChange, + label, + formData, +}) => { + const fieldObject = formData[fieldName]; + return ( + <> + {title} + + + {fieldObject?.errorMsg} + + {desc ? {desc} : null} + + ); +}; + +export default Index; diff --git a/ui/src/components/SchemaForm/components/Textarea.tsx b/ui/src/components/SchemaForm/components/Textarea.tsx new file mode 100644 index 00000000..0c94930f --- /dev/null +++ b/ui/src/components/SchemaForm/components/Textarea.tsx @@ -0,0 +1,50 @@ +import React, { FC } from 'react'; +import { Form } from 'react-bootstrap'; + +import classnames from 'classnames'; + +import type * as Type from '@/common/interface'; + +interface Props { + title: string; + desc: string | undefined; + placeholder: string | undefined; + rows: number | undefined; + className: classnames.Argument; + fieldName: string; + onChange: (evt: React.ChangeEvent, ...rest) => void; + formData: Type.FormDataType; +} +const Index: FC = ({ + title, + desc, + placeholder = '', + rows = 3, + className, + fieldName, + onChange, + formData, +}) => { + const fieldObject = formData[fieldName]; + return ( + <> + {title} + + + {fieldObject?.errorMsg} + + {desc ? {desc} : null} + + ); +}; + +export default Index; diff --git a/ui/src/components/SchemaForm/components/Timezone.tsx b/ui/src/components/SchemaForm/components/Timezone.tsx new file mode 100644 index 00000000..885ad454 --- /dev/null +++ b/ui/src/components/SchemaForm/components/Timezone.tsx @@ -0,0 +1,33 @@ +import React, { FC } from 'react'; +import { Form } from 'react-bootstrap'; + +import type * as Type from '@/common/interface'; +import TimeZonePicker from '@/components/TimeZonePicker'; + +interface Props { + title: string; + desc: string | undefined; + fieldName: string; + onChange: (evt: React.ChangeEvent, ...rest) => void; + formData: Type.FormDataType; +} +const Index: FC = ({ title, desc, fieldName, onChange, formData }) => { + const fieldObject = formData[fieldName]; + return ( + <> + {title} + + + {fieldObject?.errorMsg} + + {desc ? {desc} : null} + + ); +}; + +export default Index; diff --git a/ui/src/components/SchemaForm/components/Upload.tsx b/ui/src/components/SchemaForm/components/Upload.tsx new file mode 100644 index 00000000..fa70767f --- /dev/null +++ b/ui/src/components/SchemaForm/components/Upload.tsx @@ -0,0 +1,48 @@ +import React, { FC } from 'react'; +import { Form } from 'react-bootstrap'; + +import type * as Type from '@/common/interface'; +import BrandUpload from '@/components/BrandUpload'; + +interface Props { + title: string; + type: Type.UploadType | undefined; + acceptType: string | undefined; + desc: string | undefined; + fieldName: string; + onChange: (key, val) => void; + formData: Type.FormDataType; +} +const Index: FC = ({ + title, + type = 'avatar', + acceptType = '', + desc, + fieldName, + onChange, + formData, +}) => { + const fieldObject = formData[fieldName]; + return ( + <> + {title} + onChange(fieldName, value)} + /> + + + {fieldObject?.errorMsg} + + {desc ? {desc} : null} + + ); +}; + +export default Index; diff --git a/ui/src/components/SchemaForm/components/index.ts b/ui/src/components/SchemaForm/components/index.ts new file mode 100644 index 00000000..0db3fa35 --- /dev/null +++ b/ui/src/components/SchemaForm/components/index.ts @@ -0,0 +1,10 @@ +import Legend from './Legend'; +import Select from './Select'; +import Check from './Check'; +import Switch from './Switch'; +import Timezone from './Timezone'; +import Upload from './Upload'; +import Textarea from './Textarea'; +import Input from './Input'; + +export { Legend, Select, Check, Switch, Timezone, Upload, Textarea, Input }; diff --git a/ui/src/components/SchemaForm/index.tsx b/ui/src/components/SchemaForm/index.tsx index c57ca5b2..7be93aed 100644 --- a/ui/src/components/SchemaForm/index.tsx +++ b/ui/src/components/SchemaForm/index.tsx @@ -4,15 +4,24 @@ import { useImperativeHandle, useEffect, } from 'react'; -import { Form, Button, Stack } from 'react-bootstrap'; +import { Form, Button } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import classnames from 'classnames'; -import BrandUpload from '../BrandUpload'; -import TimeZonePicker from '../TimeZonePicker'; import type * as Type from '@/common/interface'; +import { + Legend, + Select, + Check, + Switch, + Timezone, + Upload, + Textarea, + Input, +} from './components'; + export interface JSONSchema { title: string; description?: string; @@ -32,6 +41,7 @@ export interface JSONSchema { export interface BaseUIOptions { empty?: string; className?: string | string[]; + simplify?: boolean; validator?: ( value, formData?, @@ -96,7 +106,8 @@ export type UIWidget = | 'select' | 'upload' | 'timezone' - | 'switch'; + | 'switch' + | 'legend'; export interface UISchema { [key: string]: { 'ui:widget'?: UIWidget; @@ -117,6 +128,11 @@ interface IRef { validator: () => Promise; } +/** + * TODO: + * 1. Normalize and document `formData[key].hidden && 'd-none'` + */ + /** * json schema form * @param schema json schema @@ -349,217 +365,317 @@ const SchemaForm: ForwardRefRenderFunction = ( return (
{keys.map((key) => { - const { title, description } = properties[key]; + const { + title, + description, + enum: enumValues = [], + enumNames = [], + } = properties[key]; const { 'ui:widget': widget = 'input', 'ui:options': uiOpt } = uiSchema[key] || {}; - if (widget === 'select') { - return ( - - {title} - - {properties[key].enum?.map((item, index) => { - return ( - - ); - })} - - - {formData[key]?.errorMsg} - - {description && ( - {description} - )} - - ); - } - - if (widget === 'checkbox' || widget === 'radio') { - return ( - - {title} - - {properties[key].enum?.map((item, index) => { - return ( - handleInputCheck(e, index)} - /> - ); - })} - - - {formData[key]?.errorMsg} - - {description && ( - {description} - )} - - ); - } - - if (widget === 'switch') { - return ( - - {title} - - - {formData[key]?.errorMsg} - - {description && ( - {description} - )} - - ); - } - if (widget === 'timezone') { - return ( - - {title} - - - - {formData[key]?.errorMsg} - - {description && ( - {description} - )} - - ); - } - - if (widget === 'upload') { - const options: UploadOptions = uiSchema[key]?.['ui:options'] || {}; - return ( - - {title} - handleUploadChange(key, value)} - /> - - - {formData[key]?.errorMsg} - - {description && ( - {description} - )} - - ); - } - - if (widget === 'textarea') { - const options: TextareaOptions = uiSchema[key]?.['ui:options'] || {}; - - return ( - - {title} - - - {formData[key]?.errorMsg} - - - {description && ( - {description} - )} - - ); - } - - const options: InputOptions = uiSchema[key]?.['ui:options'] || {}; - return ( - {title} - - - {formData[key]?.errorMsg} - - - {description && ( - {description} - )} + className={classnames({ + 'mb-3': widget !== 'legend' && !uiOpt?.simplify, + 'd-none': formData[key].hidden, + })}> + {widget === 'legend' ? : null} + {widget === 'select' ? ( +