From 133a76177df383f1881fd007e33e88b909052506 Mon Sep 17 00:00:00 2001 From: liuyangjuncong20202570 Date: Sat, 12 Jul 2025 17:43:09 +0800 Subject: [PATCH] feat(UI): issue related --- .../web/components/Issues/IssueDetailPage.tsx | 471 +++++++++++++----- moon/apps/web/components/Issues/IssueList.tsx | 34 ++ .../web/components/Issues/IssueNewPage.tsx | 4 +- .../web/components/Issues/IssuesContent.tsx | 2 +- .../web/components/Issues/utils/consts.ts | 2 + moon/apps/web/components/MrView/index.tsx | 52 +- .../SimpleNoteEditor/SimpleNoteContent.tsx | 31 +- .../apps/web/pages/[org]/issue/[id]/index.tsx | 7 +- moon/apps/web/pages/[org]/issue/new/index.tsx | 8 +- 9 files changed, 466 insertions(+), 145 deletions(-) diff --git a/moon/apps/web/components/Issues/IssueDetailPage.tsx b/moon/apps/web/components/Issues/IssueDetailPage.tsx index 3c1e7fe4d..7b35d6b3e 100644 --- a/moon/apps/web/components/Issues/IssueDetailPage.tsx +++ b/moon/apps/web/components/Issues/IssueDetailPage.tsx @@ -1,25 +1,36 @@ 'use client' -import React, { useCallback, useEffect, useState, useRef } from 'react' -import { Card, Flex, Space, Tabs, TabsProps, Timeline } from 'antd' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { IssueClosedIcon, IssueOpenedIcon, IssueReopenedIcon } from '@primer/octicons-react' +import { Stack } from '@primer/react' +import { ItemInput } from '@primer/react/lib/deprecated/ActionList' import { useRouter } from 'next/router' import toast from 'react-hot-toast' -import { CanvasCommentIcon, ResolveCommentIcon, PicturePlusIcon, Button } from '@gitmono/ui' +import { Button, LoadingSpinner, PicturePlusIcon } from '@gitmono/ui' -import Comment from '@/components/MrView/MRComment' +import { EMPTY_HTML } from '@/atoms/markdown' +import { useHandleBottomScrollOffset } from '@/components/NoteEditor/useHandleBottomScrollOffset' +import { ComposerReactionPicker } from '@/components/Reactions/ComposerReactionPicker' +import { SimpleNoteContent, SimpleNoteContentRef } from '@/components/SimpleNoteEditor/SimpleNoteContent' import { useGetIssueDetail } from '@/hooks/issues/useGetIssueDetail' import { usePostIssueClose } from '@/hooks/issues/usePostIssueClose' import { usePostIssueComment } from '@/hooks/issues/usePostIssueComment' import { usePostIssueReopen } from '@/hooks/issues/usePostIssueReopen' +import { useGetCurrentUser } from '@/hooks/useGetCurrentUser' +import { useGetOrganizationMember } from '@/hooks/useGetOrganizationMember' +import { useSyncedMembers } from '@/hooks/useSyncedMembers' +import { useUploadHelpers } from '@/hooks/useUploadHelpers' import { apiErrorToast } from '@/utils/apiErrorToast' -import { SimpleNoteContent, SimpleNoteContentRef } from '@/components/SimpleNoteEditor/SimpleNoteContent'; -import { EMPTY_HTML } from '@/atoms/markdown' -import { useHandleBottomScrollOffset } from '@/components/NoteEditor/useHandleBottomScrollOffset' -import { useUploadHelpers } from '@/hooks/useUploadHelpers'; -import { ComposerReactionPicker } from '@/components/Reactions/ComposerReactionPicker'; import { trimHtml } from '@/utils/trimHtml' +import { MemberAvatar } from '../MemberAvatar' +import TimelineItems from '../MrView/TimelineItems' +import { BadgeItem } from './IssueNewPage' +import { tags } from './utils/consts' +import { extractTextArray } from './utils/extractText' +import { pickWithReflect } from './utils/pickWithReflectDeep' + interface IssueDetail { status: string conversations: Conversation[] @@ -29,9 +40,9 @@ interface Conversation { id: number conv_type: string comment: string - created_at: number, - updated_at: number, - username: string, + created_at: number + updated_at: number + username: string } interface detailRes { @@ -57,13 +68,17 @@ export default function IssueDetailPage({ id }: { id: string }) { setButtonLoading((prev) => ({ ...prev, [key]: value })) } + const [closeHint, setCloseHint] = useState('Close issue') + const { mutate: closeIssue } = usePostIssueClose() const { mutate: reopenIssue } = usePostIssueReopen() const { mutate: saveComment } = usePostIssueComment() - const { data: issueDetailObj, error, isError, refetch } = useGetIssueDetail(id) + const { data: issueDetailObj, error, isError, refetch, isLoading: detailIsLoading } = useGetIssueDetail(id) + + const issueDetail = issueDetailObj?.data as IssueDetail | undefined const applyDetailData = (detail: detailRes | undefined) => { if (!detail || !detail.req_result) return @@ -105,7 +120,37 @@ export default function IssueDetailPage({ id }: { id: string }) { }) } + const save_comment = useCallback(() => { + const currentContentHTML = editorRef.current?.editor?.getHTML() ?? '

' + + if (trimHtml(currentContentHTML) === '') { + toast.error('comment can not be empty!') + return + } + setLoading('comment', true) + set_to_loading(3) + saveComment( + { link: id, data: { content: currentContentHTML } }, + { + onSuccess: async () => { + editorRef.current?.clearAndBlur() + const { data: issueDetailObj } = await refetch({ throwOnError: true }) + + applyDetailData(issueDetailObj) + cancel_loading(3) + toast.success('comment successfully!') + }, + onError: apiErrorToast, + onSettled: () => setLoading('comment', false) + } + ) + }, [id, refetch, saveComment]) + const close_issue = useCallback(() => { + if (closeHint === 'Close with comment') { + save_comment() + } + setLoading('close', true) set_to_loading(3) closeIssue( @@ -119,7 +164,7 @@ export default function IssueDetailPage({ id }: { id: string }) { onSettled: () => setLoading('close', false) } ) - }, [id, router, closeIssue]) + }, [id, router, closeIssue, closeHint, save_comment]) const reopen_issue = useCallback(() => { setLoading('reopen', true) @@ -136,58 +181,7 @@ export default function IssueDetailPage({ id }: { id: string }) { ) }, [id, router, reopenIssue]) - const save_comment = useCallback( - () => { - const currentContentHTML = editorRef.current?.editor?.getHTML() ?? '

'; - - if (trimHtml(currentContentHTML) === '') { - toast.error('comment can not be empty!') - return - } - setLoading('comment', true) - set_to_loading(3) - saveComment( - { link: id, data: { content: currentContentHTML } }, - { - onSuccess: async () => { - editorRef.current?.clearAndBlur() - const { data: issueDetailObj } = await refetch({ throwOnError: true }) - - applyDetailData(issueDetailObj) - cancel_loading(3) - toast.success('comment successfully!') - }, - onError: apiErrorToast, - onSettled: () => setLoading('comment', false) - } - ) - }, - [id, refetch, saveComment] - ) - - const conv_items = info?.conversations.map((conv) => { - let icon - let children - - switch (conv.conv_type) { - case 'Comment': - icon = - children = - break - case 'Closed': - icon = - children = conv.comment - } - - const element = { - dot: icon, - children: children - } - - return element - }) - - const editorRef = useRef(null); + const editorRef = useRef(null) const onKeyDownScrollHandler = useHandleBottomScrollOffset({ editor: editorRef.current?.editor }) @@ -195,75 +189,286 @@ export default function IssueDetailPage({ id }: { id: string }) { upload: editorRef.current?.uploadAndAppendAttachments }) - const tab_items: TabsProps['items'] = [ - { - key: '1', - label: 'Conversation', - children: ( - - - {info && info.status === 'open' && ( - <> -
-

Add a comment

- -
- -
-
- - - - - - )} - {info && info.status === 'closed' && ( - - - - )} -
- ) + const { data } = useGetCurrentUser() + const { data: user } = useGetOrganizationMember({ username: data?.username }) + + const avatarUser = useMemo(() => { + if (!user) return undefined + return { + deactivated: user['deactivated'], + user: pickWithReflect(user?.user ?? {}, [ + 'id', + 'display_name', + 'username', + 'avatar_urls', + 'notifications_paused', + 'integration' + ]) } - ] + }, [user]) + + const splitFun = (el: React.ReactNode): string[] => { + return extractTextArray(el) + .flatMap((name) => name.split(',').map((n) => n.trim())) + .filter((n) => n.length > 0) + } + + const { members } = useSyncedMembers() + + const avatars: ItemInput[] = useMemo( + () => + members?.map((i) => ({ + groupId: 'end', + text: i.user.display_name, + leadingVisual: () => + })) || [], + [members] + ) + // const [avatarTems, setAvatarItems] = useState(avatars) + + const labels: ItemInput[] = useMemo( + () => + tags.map((i) => ({ + text: i.description, + leadingVisual: () => ( +
+ ) + })), + [] + ) + + const memberMap = useMemo(() => { + const map = new Map() + + members?.forEach((i) => { + map.set(i.user.display_name, i) + }) + return map + }, [members]) + + const labelMap = useMemo(() => { + const map = new Map() + + tags.map((i) => { + map.set(i.description, i) + }) + return map + }, []) + + const handleChange = (html: string) => { + if (html && html === '

') { + setCloseHint('Close issue') + } else { + setCloseHint('Close with comment') + } + } return ( - - - + <> +
+ {info.title && ( +
+
{info.title}
+ {info.status === 'open' ? ( + <> + + + Open + + + ) : ( + <> + + + Closed + + + )} +
+ )} +
+ {avatarUser && } + + +
+
+ {detailIsLoading ? ( +
+ +
+ ) : ( + + )} + + {info && info.status === 'open' && ( + <> +
+

Add a comment

+ +
+ handleChange(html)} + /> +
+
+
+ + +
+ + )} + + {info && info.status === 'closed' && ( + <> +
+

Add a comment

+ +
+ +
+
+
+ + +
+ + )} +
+ {/* */} +
+ + {(el) => { + const names = Array.from(new Set(splitFun(el))) + + return ( + <> + {names.map((i, index) => ( + // eslint-disable-next-line react/no-array-index-key +
+ + {i} +
+ ))} + + ) + }} +
+ + {(el) => { + const names = splitFun(el) + + return ( + <> +
+ {names.map((i, index) => { + const label = labelMap.get(i) ?? {} + + return ( + // eslint-disable-next-line react/no-array-index-key +
+
+ {label.name} +
+
+ ) + })} +
+ + ) + }} +
+ + + +
+
+
+
+
+ ) } diff --git a/moon/apps/web/components/Issues/IssueList.tsx b/moon/apps/web/components/Issues/IssueList.tsx index ab1339eef..1876f4ee6 100644 --- a/moon/apps/web/components/Issues/IssueList.tsx +++ b/moon/apps/web/components/Issues/IssueList.tsx @@ -192,6 +192,40 @@ export const DropdownOrder = ({ ) } +export const DropdownReview = ({ + name, + dropdownArr, + dropdownItem, + onOpen, + open +}: { + name: string + dropdownArr: MenuItem[] + dropdownItem?: MenuItem[] + onOpen?: (open: boolean) => void + open?: boolean + inside?: React.ReactNode +}) => { + return ( + <> + +
+ {name} +
+ + } + /> + + ) +} // dropdownArr是不一样的,其他一样 export const Dropdown = ({ diff --git a/moon/apps/web/components/Issues/IssueNewPage.tsx b/moon/apps/web/components/Issues/IssueNewPage.tsx index 970ebda68..fb66475e3 100644 --- a/moon/apps/web/components/Issues/IssueNewPage.tsx +++ b/moon/apps/web/components/Issues/IssueNewPage.tsx @@ -310,7 +310,7 @@ type SelectPanelExcludedProps = | 'renderAnchor' | 'variant' -const BadgeItem = ({ +export const BadgeItem = ({ title, selectPannelProps, items, @@ -376,7 +376,7 @@ const BadgeItem = ({ ) } -const SideBarItem = ({ emptyState }: { emptyState: string }) => { +export const SideBarItem = ({ emptyState }: { emptyState: string }) => { return ( <>
diff --git a/moon/apps/web/components/Issues/IssuesContent.tsx b/moon/apps/web/components/Issues/IssuesContent.tsx index 2f573c550..51ad6d0d7 100644 --- a/moon/apps/web/components/Issues/IssuesContent.tsx +++ b/moon/apps/web/components/Issues/IssuesContent.tsx @@ -430,7 +430,7 @@ export const RightAvatar = ({ member, commentNum }: { member?: Member; commentNu
- {commentNum} + {commentNum !== 0 && {commentNum}}
{member && ( diff --git a/moon/apps/web/components/Issues/utils/consts.ts b/moon/apps/web/components/Issues/utils/consts.ts index c73f282ac..8a696131c 100644 --- a/moon/apps/web/components/Issues/utils/consts.ts +++ b/moon/apps/web/components/Issues/utils/consts.ts @@ -39,6 +39,8 @@ export const tags: LabelItem[] = [ export const orderTags = ['Created on', 'Last updated', 'Total comments', 'Best match', 'Oldest', 'Newest'] +export const reviewTags = ['No reviews', 'Review required', 'Approved review', 'Changes requested'] + export const searchList: SearchType = [ { type: 'item', diff --git a/moon/apps/web/components/MrView/index.tsx b/moon/apps/web/components/MrView/index.tsx index f9d619546..e3455dfa7 100644 --- a/moon/apps/web/components/MrView/index.tsx +++ b/moon/apps/web/components/MrView/index.tsx @@ -16,6 +16,7 @@ import { DropdownItemwithAvatar, DropdownItemwithLabel, DropdownOrder, + DropdownReview, ListBanner, ListItem as MrItem, IssueList as MrList @@ -29,7 +30,7 @@ import { atomWithWebStorage } from '@/utils/atomWithWebStorage' import { IndexPageContainer, IndexPageContent } from '../IndexPages/components' import { AdditionType, RightAvatar } from '../Issues/IssuesContent' import { Pagination } from '../Issues/Pagenation' -import { orderTags, tags } from '../Issues/utils/consts' +import { orderTags, reviewTags, tags } from '../Issues/utils/consts' import { generateAllMenuItems, MenuConfig } from '../Issues/utils/generateAllMenuItems' import { filterAtom, sortAtom } from '../Issues/utils/store' import { Heading } from './catalyst/heading' @@ -63,12 +64,16 @@ export default function MrView() { [scope] ) + const reviewAtom = useMemo(() => atomWithWebStorage(`${scope}:mr-review`, ''), [scope]) + const labelAtom = useMemo(() => atomWithWebStorage(`${scope}:mr-label`, []), [scope]) const [order, setOrder] = useAtom(orderAtom) const [label, setLabel] = useAtom(labelAtom) + const [review, setReview] = useAtom(reviewAtom) + const additions = useCallback( (labels: number[]): AdditionType => { const additional: AdditionType = { status, asc: false } @@ -240,6 +245,29 @@ export default function MrView() { } ] + const ReviewConfig: MenuConfig[] = [ + { + key: 'Review', + isChosen: () => true, + + onSelectFactory: (item) => (e: Event) => { + e.preventDefault() + if (item === review) { + setReview('') + } else { + setReview(item) + } + }, + className: 'overflow-hidden', + labelFactory: (item) => ( +
+
{review === item && }
+ {item} +
+ ) + } + ] + const OrderConfig: MenuConfig[] = [ { key: 'Order', @@ -292,6 +320,8 @@ export default function MrView() { const orders = generateAllMenuItems(orderTags, OrderConfig) + const reviews = generateAllMenuItems(reviewTags, ReviewConfig) + const ListHeaderItem = (p: string) => { switch (p) { case 'Author': @@ -314,6 +344,15 @@ export default function MrView() { dropdownItem={member?.get('Assignees').chosen} /> ) + case 'Reviews': + return ( + + ) case 'Labels': return ( } > {(p) => ListHeaderItem(p)} diff --git a/moon/apps/web/components/SimpleNoteEditor/SimpleNoteContent.tsx b/moon/apps/web/components/SimpleNoteEditor/SimpleNoteContent.tsx index e35f9c718..bf3b30460 100644 --- a/moon/apps/web/components/SimpleNoteEditor/SimpleNoteContent.tsx +++ b/moon/apps/web/components/SimpleNoteEditor/SimpleNoteContent.tsx @@ -1,4 +1,14 @@ -import { DragEvent, forwardRef, KeyboardEvent, memo, MouseEvent, useImperativeHandle, useRef, useState } from 'react' +import { + DragEvent, + forwardRef, + KeyboardEvent, + memo, + MouseEvent, + useEffect, + useImperativeHandle, + useRef, + useState +} from 'react' import { Editor as TTEditor } from '@tiptap/core' import { EditorContent } from '@tiptap/react' @@ -28,6 +38,7 @@ interface Props { content: string onBlurAtTop?: BlurAtTopOptions['onBlur'] onKeyDown?: (event: KeyboardEvent) => void + onChange?: (html: string) => void } export interface SimpleNoteContentRef { @@ -42,7 +53,7 @@ export interface SimpleNoteContentRef { export const SimpleNoteContent = memo( forwardRef((props, ref) => { - const { commentId, editable = 'viewer', autofocus = false, onBlurAtTop, content } = props + const { commentId, editable = 'viewer', autofocus = false, onBlurAtTop, content, onChange } = props const [activeComment, setActiveComment] = useState(null) const [hoverComment, setHoverComment] = useState(null) @@ -120,6 +131,22 @@ export const SimpleNoteContent = memo( enabled: true }) + useEffect(() => { + if (!editor || !onChange) return + + const handleUpdate = () => { + const html = editor.getHTML() + + onChange?.(html) + } + + editor.on('update', handleUpdate) + + return () => { + editor.off('update', handleUpdate) + } + }, [editor, onChange]) + return (
{ const OrganizationIssueDetailPage: PageWithLayout = ({ id }) => { return ( <> - + + + + + ) } diff --git a/moon/apps/web/pages/[org]/issue/new/index.tsx b/moon/apps/web/pages/[org]/issue/new/index.tsx index 8b3fd3ebd..9f2d7080c 100644 --- a/moon/apps/web/pages/[org]/issue/new/index.tsx +++ b/moon/apps/web/pages/[org]/issue/new/index.tsx @@ -12,10 +12,10 @@ const OrganizationIssueNewPage: PageWithLayout = () => { return ( <> - - - - + + + + ) }