This is an automated email from the ASF dual-hosted git repository. shuai pushed a commit to branch dev in repository https://gitbox.apache.org/repos/asf/answer.git
commit 9c0844f0ed29ff8caaad6491262a48fdfb5764ab Author: hgaol <dhan...@hotmail.com> AuthorDate: Sat Apr 19 19:24:25 2025 +0800 feat: implement MergeTag functionality with UI and backend support --- i18n/en_US.yaml | 10 + internal/controller/tag_controller.go | 1 + internal/schema/tag_schema.go | 2 + internal/service/permission/permission_name.go | 3 +- internal/service/permission/tag_permission.go | 11 +- internal/service/tag/tag_service.go | 2 +- internal/service/tag_common/tag_common.go | 2 + ui/src/components/Modal/MergeTagModal.tsx | 233 +++++++++++++++++++++ ui/src/components/Modal/index.tsx | 3 +- ui/src/pages/Tags/Info/index.tsx | 276 ++++++++++++++----------- ui/src/services/client/tag.ts | 6 + 11 files changed, 421 insertions(+), 128 deletions(-) diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index f220b454..444eb826 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -58,6 +58,8 @@ backend: other: Edit undelete: other: Undelete + merge: + other: Merge role: name: user: @@ -1041,6 +1043,14 @@ ui: <p>Please remove the synonyms from this tag first.</p> tip: Are you sure you wish to delete? close: Close + merge: + title: Merge tag + source_tag_title: Source tag + source_tag_description: The source tag and its associated data will be remapped to the target tag. + target_tag_title: Target tag + target_tag_description: A synonym between these two tags will be created after merging. + btn_submit: Submit + btn_close: Close edit_tag: title: Edit Tag default_reason: Edit tag diff --git a/internal/controller/tag_controller.go b/internal/controller/tag_controller.go index 765497bc..56555cd8 100644 --- a/internal/controller/tag_controller.go +++ b/internal/controller/tag_controller.go @@ -249,6 +249,7 @@ func (tc *TagController) GetTagInfo(ctx *gin.Context) { req.CanEdit = canList[0] req.CanDelete = canList[1] req.CanRecover = canList[2] + req.CanMerge = middleware.GetUserIsAdminModerator(ctx) resp, err := tc.tagService.GetTagInfo(ctx, req) handler.HandleResponse(ctx, err, resp) diff --git a/internal/schema/tag_schema.go b/internal/schema/tag_schema.go index b5a5835b..a51b2d2d 100644 --- a/internal/schema/tag_schema.go +++ b/internal/schema/tag_schema.go @@ -48,6 +48,7 @@ type GetTagInfoReq struct { UserID string `json:"-"` CanEdit bool `json:"-"` CanDelete bool `json:"-"` + CanMerge bool `json:"-"` CanRecover bool `json:"-"` } @@ -300,6 +301,7 @@ type GetFollowingTagsResp struct { // GetTagBasicResp get tag basic response type GetTagBasicResp struct { + TagID string `json:"tag_id"` SlugName string `json:"slug_name"` DisplayName string `json:"display_name"` Recommend bool `json:"recommend"` diff --git a/internal/service/permission/permission_name.go b/internal/service/permission/permission_name.go index 5c93e89e..fb9fbb21 100644 --- a/internal/service/permission/permission_name.go +++ b/internal/service/permission/permission_name.go @@ -52,8 +52,8 @@ const ( TagEditSlugName = "tag.edit_slug_name" TagEditWithoutReview = "tag.edit_without_review" TagDelete = "tag.delete" - TagSynonym = "tag.synonym" TagMerge = "tag.merge" + TagSynonym = "tag.synonym" LinkUrlLimit = "link.url_limit" VoteDetail = "vote.detail" AnswerAudit = "answer.audit" @@ -69,6 +69,7 @@ const ( reportActionName = "action.report" editActionName = "action.edit" deleteActionName = "action.delete" + mergeActionName = "action.merge" undeleteActionName = "action.undelete" closeActionName = "action.close" reopenActionName = "action.reopen" diff --git a/internal/service/permission/tag_permission.go b/internal/service/permission/tag_permission.go index 53161033..67ac2fa0 100644 --- a/internal/service/permission/tag_permission.go +++ b/internal/service/permission/tag_permission.go @@ -21,6 +21,7 @@ package permission import ( "context" + "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/base/handler" @@ -29,7 +30,7 @@ import ( ) // GetTagPermission get tag permission -func GetTagPermission(ctx context.Context, status int, canEdit, canDelete, canRecover bool) ( +func GetTagPermission(ctx context.Context, status int, canEdit, canDelete, canMerge, canRecover bool) ( actions []*schema.PermissionMemberAction) { lang := handler.GetLangByCtx(ctx) actions = make([]*schema.PermissionMemberAction, 0) @@ -49,6 +50,14 @@ func GetTagPermission(ctx context.Context, status int, canEdit, canDelete, canRe }) } + if canMerge && status != entity.TagStatusDeleted { + actions = append(actions, &schema.PermissionMemberAction{ + Action: "merge", + Name: translator.Tr(lang, mergeActionName), + Type: "edit", + }) + } + if canRecover && status == entity.QuestionStatusDeleted { actions = append(actions, &schema.PermissionMemberAction{ Action: "undelete", diff --git a/internal/service/tag/tag_service.go b/internal/service/tag/tag_service.go index 8b614a7a..a5df5fe8 100644 --- a/internal/service/tag/tag_service.go +++ b/internal/service/tag/tag_service.go @@ -186,7 +186,7 @@ func (ts *TagService) GetTagInfo(ctx context.Context, req *schema.GetTagInfoReq) resp.Reserved = tagInfo.Reserved resp.IsFollower = ts.checkTagIsFollow(ctx, req.UserID, tagInfo.ID) resp.Status = entity.TagStatusDisplayMapping[tagInfo.Status] - resp.MemberActions = permission.GetTagPermission(ctx, tagInfo.Status, req.CanEdit, req.CanDelete, req.CanRecover) + resp.MemberActions = permission.GetTagPermission(ctx, tagInfo.Status, req.CanEdit, req.CanDelete, req.CanMerge, req.CanRecover) resp.GetExcerpt() return resp, nil } diff --git a/internal/service/tag_common/tag_common.go b/internal/service/tag_common/tag_common.go index 5f59e374..df7c7ef2 100644 --- a/internal/service/tag_common/tag_common.go +++ b/internal/service/tag_common/tag_common.go @@ -137,6 +137,7 @@ func (ts *TagCommonService) SearchTagLike(ctx context.Context, req *schema.Searc } mainTagID := converter.IntToString(tag.MainTagID) if _, ok := mainTagMap[mainTagID]; ok { + tag.ID = mainTagMap[mainTagID].ID tag.SlugName = mainTagMap[mainTagID].SlugName tag.DisplayName = mainTagMap[mainTagID].DisplayName tag.Reserved = mainTagMap[mainTagID].Reserved @@ -148,6 +149,7 @@ func (ts *TagCommonService) SearchTagLike(ctx context.Context, req *schema.Searc for _, tag := range tags { if _, ok := repetitiveTag[tag.SlugName]; !ok { item := schema.GetTagBasicResp{} + item.TagID = tag.ID item.SlugName = tag.SlugName item.DisplayName = tag.DisplayName item.Recommend = tag.Recommend diff --git a/ui/src/components/Modal/MergeTagModal.tsx b/ui/src/components/Modal/MergeTagModal.tsx new file mode 100644 index 00000000..6441331e --- /dev/null +++ b/ui/src/components/Modal/MergeTagModal.tsx @@ -0,0 +1,233 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FC, useState, useEffect, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Form, Dropdown } from 'react-bootstrap'; + +import { TagInfo } from '@/common/interface'; +import request from '@/utils/request'; + +import Modal from './Modal'; + +interface Props { + visible: boolean; + sourceTag: TagInfo; + onClose: () => void; + onConfirm: (sourceTagID: string, targetTagID: string) => void; +} + +interface SearchTagResp { + tag_id: string; + slug_name: string; + display_name: string; + recommend: boolean; + reserved: boolean; +} + +const MergeTagModal: FC<Props> = ({ + visible, + sourceTag, + onClose, + onConfirm, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'tag_info.merge', + }); + const [targetTag, setTargetTag] = useState<SearchTagResp | null>(null); + const [searchValue, setSearchValue] = useState(''); + const [tags, setTags] = useState<SearchTagResp[]>([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [dropdownVisible, setDropdownVisible] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const inputRef = useRef<HTMLInputElement>(null); + + const searchTags = async (search: string) => { + try { + const res = await request.get<SearchTagResp[]>( + '/answer/api/v1/question/tags', + { + params: { tag: search }, + }, + ); + // Filter out the source tag from results + const filteredTags = res.filter( + (tag) => tag.slug_name !== sourceTag.slug_name, + ); + setTags(filteredTags); + if (filteredTags.length > 0 && isFocused) { + setDropdownVisible(true); + } + } catch (error) { + console.error('Failed to search tags:', error); + setTags([]); + } + }; + + // Debounced search function + const debouncedSearch = useCallback( + (() => { + let timeout: number | undefined; + return (search: string) => { + if (timeout) { + clearTimeout(timeout); + } + timeout = window.setTimeout(() => { + searchTags(search); + }, 1000); + }; + })(), + [isFocused], + ); + + useEffect(() => { + if (visible) { + searchTags(''); + setSearchValue(''); + setTargetTag(null); + setCurrentIndex(0); + setDropdownVisible(false); + setIsFocused(false); + } + }, [visible]); + + const handleConfirm = () => { + if (!targetTag) return; + onConfirm(sourceTag.tag_id, targetTag.tag_id); + }; + + const handleSelect = (tag: SearchTagResp) => { + setTargetTag(tag); + setDropdownVisible(false); + setSearchValue(tag.display_name); + setIsFocused(false); + inputRef.current?.blur(); + }; + + const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => { + const { value } = e.target; + setSearchValue(value); + if (value) { + debouncedSearch(value); + } else { + searchTags(''); + } + }; + + const handleFocus = () => { + setIsFocused(true); + setDropdownVisible(true); + }; + + const handleBlur = () => { + // Use setTimeout to allow click events on dropdown items to fire before closing + setTimeout(() => { + setIsFocused(false); + if (!targetTag) { + setDropdownVisible(false); + } + }, 200); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!tags.length) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setCurrentIndex((prev) => (prev < tags.length - 1 ? prev + 1 : prev)); + break; + case 'ArrowUp': + e.preventDefault(); + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : prev)); + break; + case 'Enter': + e.preventDefault(); + if (currentIndex >= 0 && currentIndex < tags.length) { + handleSelect(tags[currentIndex]); + } + break; + case 'Escape': + e.preventDefault(); + inputRef.current?.blur(); + setDropdownVisible(false); + break; + default: + break; + } + }; + + return ( + <Modal + title={t('title')} + visible={visible} + onCancel={onClose} + onConfirm={handleConfirm} + confirmText={t('btn_submit')} + confirmBtnVariant="primary" + cancelText={t('btn_close')} + cancelBtnVariant="link" + confirmBtnDisabled={!targetTag}> + <Form> + <Form.Group className="mb-3"> + <Form.Label>{t('source_tag_title')}</Form.Label> + <Form.Control value={sourceTag.display_name} disabled /> + <Form.Text className="text-muted"> + {t('source_tag_description')} + </Form.Text> + </Form.Group> + <Form.Group> + <Form.Label>{t('target_tag_title')}</Form.Label> + <Dropdown + show={dropdownVisible && (tags.length > 0 || Boolean(searchValue))} + onToggle={setDropdownVisible}> + <Form.Control + ref={inputRef} + type="text" + value={searchValue} + onChange={handleSearch} + onKeyDown={handleKeyDown} + onFocus={handleFocus} + onBlur={handleBlur} + autoComplete="off" + /> + <Dropdown.Menu className="w-100"> + {tags.map((tag, index) => ( + <Dropdown.Item + key={tag.slug_name} + active={index === currentIndex} + onClick={() => handleSelect(tag)}> + {tag.display_name} + </Dropdown.Item> + ))} + {tags.length === 0 && searchValue && ( + <Dropdown.Item disabled>{t('no_results')}</Dropdown.Item> + )} + </Dropdown.Menu> + </Dropdown> + <Form.Text className="text-muted"> + {t('target_tag_description')} + </Form.Text> + </Form.Group> + </Form> + </Modal> + ); +}; + +export default MergeTagModal; diff --git a/ui/src/components/Modal/index.tsx b/ui/src/components/Modal/index.tsx index 75d96842..6d95c312 100644 --- a/ui/src/components/Modal/index.tsx +++ b/ui/src/components/Modal/index.tsx @@ -21,6 +21,7 @@ import DefaultModal from './Modal'; import confirm, { Config } from './Confirm'; import LoginToContinueModal from './LoginToContinueModal'; import BadgeModal from './BadgeModal'; +import MergeTagModal from './MergeTagModal'; type ModalType = typeof DefaultModal & { confirm: (config: Config) => void; @@ -33,4 +34,4 @@ Modal.confirm = function (props: Config) { export default Modal; -export { LoginToContinueModal, BadgeModal }; +export { LoginToContinueModal, BadgeModal, MergeTagModal }; diff --git a/ui/src/pages/Tags/Info/index.tsx b/ui/src/pages/Tags/Info/index.tsx index 1c32e249..3a9d21ee 100644 --- a/ui/src/pages/Tags/Info/index.tsx +++ b/ui/src/pages/Tags/Info/index.tsx @@ -33,9 +33,11 @@ import { deleteTag, editCheck, unDeleteTag, + mergeTag, } from '@/services'; import { pathFactory } from '@/router/pathFactory'; import { loggedUserInfoStore, toastStore } from '@/stores'; +import { MergeTagModal } from '@/components/Modal'; const TagIntroduction = () => { const userInfo = loggedUserInfoStore((state) => state.user); @@ -52,6 +54,8 @@ const TagIntroduction = () => { tagInfo?.tag_id, tagInfo?.status, ); + const [showMergeModal, setShowMergeModal] = useState(false); + let pageTitle = ''; if (tagInfo) { pageTitle = `'${tagInfo.display_name}' ${t('tag_wiki', { @@ -156,6 +160,19 @@ const TagIntroduction = () => { }, }); }; + const handleMergeTag = () => { + setShowMergeModal(true); + }; + + const handleMergeConfirm = (sourceTagID: string, targetTagID: string) => { + mergeTag({ source_tag_id: sourceTagID, target_tag_id: targetTagID }).then( + () => { + setShowMergeModal(false); + navigate('/tags', { replace: true }); + }, + ); + }; + const onAction = (params) => { if (params.action === 'edit') { handleEditTag(); @@ -163,6 +180,9 @@ const TagIntroduction = () => { if (params.action === 'delete') { handleDeleteTag(); } + if (params.action === 'merge') { + handleMergeTag(); + } if (params.action === 'undelete') { Modal.confirm({ title: t('undelete_title', { keyPrefix: 'delete' }), @@ -181,136 +201,144 @@ const TagIntroduction = () => { }; return ( - <Row className="pt-4 mb-5"> - <Col className="page-main flex-auto"> - {tagInfo?.status === 'deleted' && ( - <Alert variant="danger" className="mb-4"> - {t('post_deleted', { keyPrefix: 'messages' })} - </Alert> - )} - <h3 className="mb-3"> - <Link - to={pathFactory.tagLanding(tagInfo.slug_name)} - replace - className="link-dark"> - {tagInfo.display_name} - </Link> - </h3> + <> + <Row className="pt-4 mb-5"> + <Col className="page-main flex-auto"> + {tagInfo?.status === 'deleted' && ( + <Alert variant="danger" className="mb-4"> + {t('post_deleted', { keyPrefix: 'messages' })} + </Alert> + )} + <h3 className="mb-3"> + <Link + to={pathFactory.tagLanding(tagInfo.slug_name)} + replace + className="link-dark"> + {tagInfo.display_name} + </Link> + </h3> - <div className="text-secondary mb-4 small"> - <FormatTime preFix={t('created_at')} time={tagInfo.created_at} /> - <FormatTime - preFix={t('edited_at')} - className="ms-3" - time={tagInfo.updated_at} - /> - </div> + <div className="text-secondary mb-4 small"> + <FormatTime preFix={t('created_at')} time={tagInfo.created_at} /> + <FormatTime + preFix={t('edited_at')} + className="ms-3" + time={tagInfo.updated_at} + /> + </div> - <div - className="content text-break fmt" - dangerouslySetInnerHTML={{ __html: tagInfo?.parsed_text }} - /> - <div className="mt-4"> - {tagInfo?.member_actions.map((action, index) => { - return ( - <Button - key={action.name} - variant="link" - size="sm" + <div + className="content text-break fmt" + dangerouslySetInnerHTML={{ __html: tagInfo?.parsed_text }} + /> + <div className="mt-4"> + {tagInfo?.member_actions.map((action, index) => { + return ( + <Button + key={action.name} + variant="link" + size="sm" + className={classNames( + 'link-secondary btn-no-border p-0', + index > 0 && 'ms-3', + )} + onClick={() => onAction(action)}> + {action.name} + </Button> + ); + })} + {isLogged && ( + <Link + to={`/tags/${tagInfo?.tag_id}/timeline`} className={classNames( - 'link-secondary btn-no-border p-0', - index > 0 && 'ms-3', - )} - onClick={() => onAction(action)}> - {action.name} - </Button> - ); - })} - {isLogged && ( - <Link - to={`/tags/${tagInfo?.tag_id}/timeline`} - className={classNames( - 'link-secondary btn-no-border p-0 small', - tagInfo?.member_actions?.length > 0 && 'ms-3', - )}> - {t('history')} - </Link> - )} - </div> - </Col> - <Col className="page-right-side mt-4 mt-xl-0"> - <Card> - <Card.Header className="d-flex justify-content-between"> - <span>{t('synonyms.title')}</span> - {isEdit ? ( - <Button - variant="link" - className="p-0 btn-no-border" - onClick={handleSave}> - {t('synonyms.btn_save')} - </Button> - ) : synonymsData?.member_actions?.find( - (v) => v.action === 'edit', - ) ? ( - <Button - variant="link" - className="p-0 btn-no-border" - onClick={handleEdit}> - {t('synonyms.btn_edit')} - </Button> - ) : null} - </Card.Header> - <Card.Body> - {isEdit && ( - <> - <div className="mb-3"> - {t('synonyms.text')}{' '} - <Tag - data={{ - slug_name: tagName || '', - main_tag_slug_name: '', - display_name: - tagInfo?.display_name || tagInfo?.slug_name || '', - recommend: false, - reserved: false, - }} - /> - </div> - <TagSelector - value={synonymsData?.synonyms} - onChange={handleTagsChange} - hiddenDescription - /> - </> + 'link-secondary btn-no-border p-0 small', + tagInfo?.member_actions?.length > 0 && 'ms-3', + )}> + {t('history')} + </Link> )} - {!isEdit && - (synonymsData?.synonyms && synonymsData.synonyms.length > 0 ? ( - <div className="m-n1"> - {synonymsData.synonyms.map((item) => { - return ( - <Tag key={item.tag_id} className="m-1" data={item} /> - ); - })} - </div> - ) : ( + </div> + </Col> + <Col className="page-right-side mt-4 mt-xl-0"> + <Card> + <Card.Header className="d-flex justify-content-between"> + <span>{t('synonyms.title')}</span> + {isEdit ? ( + <Button + variant="link" + className="p-0 btn-no-border" + onClick={handleSave}> + {t('synonyms.btn_save')} + </Button> + ) : synonymsData?.member_actions?.find( + (v) => v.action === 'edit', + ) ? ( + <Button + variant="link" + className="p-0 btn-no-border" + onClick={handleEdit}> + {t('synonyms.btn_edit')} + </Button> + ) : null} + </Card.Header> + <Card.Body> + {isEdit && ( <> - <div className="text-muted mb-3">{t('synonyms.empty')}</div> - {synonymsData?.member_actions?.find( - (v) => v.action === 'edit', - ) && ( - <Button - variant="outline-primary" - size="sm" - onClick={handleEdit}> - {t('synonyms.btn_add')} - </Button> - )} + <div className="mb-3"> + {t('synonyms.text')}{' '} + <Tag + data={{ + slug_name: tagName || '', + main_tag_slug_name: '', + display_name: + tagInfo?.display_name || tagInfo?.slug_name || '', + recommend: false, + reserved: false, + }} + /> + </div> + <TagSelector + value={synonymsData?.synonyms} + onChange={handleTagsChange} + hiddenDescription + /> </> - ))} - </Card.Body> - </Card> - </Col> - </Row> + )} + {!isEdit && + (synonymsData?.synonyms && synonymsData.synonyms.length > 0 ? ( + <div className="m-n1"> + {synonymsData.synonyms.map((item) => { + return ( + <Tag key={item.tag_id} className="m-1" data={item} /> + ); + })} + </div> + ) : ( + <> + <div className="text-muted mb-3">{t('synonyms.empty')}</div> + {synonymsData?.member_actions?.find( + (v) => v.action === 'edit', + ) && ( + <Button + variant="outline-primary" + size="sm" + onClick={handleEdit}> + {t('synonyms.btn_add')} + </Button> + )} + </> + ))} + </Card.Body> + </Card> + </Col> + </Row> + <MergeTagModal + visible={showMergeModal} + sourceTag={tagInfo} + onClose={() => setShowMergeModal(false)} + onConfirm={handleMergeConfirm} + /> + </> ); }; diff --git a/ui/src/services/client/tag.ts b/ui/src/services/client/tag.ts index 25741315..b94e7876 100644 --- a/ui/src/services/client/tag.ts +++ b/ui/src/services/client/tag.ts @@ -31,6 +31,12 @@ export const deleteTag = (id) => { export const modifyTag = (params) => { return request.put('/answer/api/v1/tag', params); }; +export const mergeTag = (params: { + source_tag_id: string; + target_tag_id: string; +}) => { + return request.post('/answer/api/v1/tag/merge', params); +}; export const useQuerySynonymsTags = (tagId, status) => { const apiUrl =