From a217f632939361ba4b5efda14999733d22f1da8b Mon Sep 17 00:00:00 2001 From: liuyangjuncong20202570 Date: Sun, 29 Jun 2025 16:17:53 +0800 Subject: [PATCH] feat(UI): rebuild new issue page & fix some problems --- .../web/components/Issues/IssueNewPage.tsx | 414 +++++++++++++---- .../web/components/Issues/IssuesContent.tsx | 24 +- .../components/Issues/utils/extractText.ts | 20 + .../components/Issues/utils/mergeDuplicate.ts | 22 + .../Issues/utils/pickWithReflectDeep.ts | 8 + moon/apps/web/components/MrView/index.tsx | 174 +++++++- moon/apps/web/next.config.js | 3 +- moon/apps/web/package.json | 3 + moon/apps/web/pages/[org]/issue/new/index.tsx | 9 +- moon/pnpm-lock.yaml | 418 ++++++++++++++++-- 10 files changed, 978 insertions(+), 117 deletions(-) create mode 100644 moon/apps/web/components/Issues/utils/extractText.ts create mode 100644 moon/apps/web/components/Issues/utils/mergeDuplicate.ts create mode 100644 moon/apps/web/components/Issues/utils/pickWithReflectDeep.ts diff --git a/moon/apps/web/components/Issues/IssueNewPage.tsx b/moon/apps/web/components/Issues/IssueNewPage.tsx index d5966da22..2970b0474 100644 --- a/moon/apps/web/components/Issues/IssueNewPage.tsx +++ b/moon/apps/web/components/Issues/IssueNewPage.tsx @@ -1,25 +1,91 @@ 'use client' -import { useCallback, useState, useRef } from 'react' -import { Flex, Input, Space } from 'antd/lib' +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { ActionList, FormControl, SelectPanel, Stack, Text, TextInput } from '@primer/react' +import { ItemInput } from '@primer/react/lib/deprecated/ActionList' import { useRouter } from 'next/router' import toast from 'react-hot-toast' -import { Button, LargeTitle, PicturePlusIcon } from '@gitmono/ui' -import { usePostIssueSubmit } from '@/hooks/issues/usePostIssueSubmit' -import { apiErrorToast } from '@/utils/apiErrorToast' -import { SimpleNoteContent, SimpleNoteContentRef } from '@/components/SimpleNoteEditor/SimpleNoteContent'; +import { Button, HelpIcon, Link, PicturePlusIcon } from '@gitmono/ui' + import { EMPTY_HTML } from '@/atoms/markdown' import { useHandleBottomScrollOffset } from '@/components/NoteEditor/useHandleBottomScrollOffset' -import { useUploadHelpers } from '@/hooks/useUploadHelpers'; -import { ComposerReactionPicker } from '@/components/Reactions/ComposerReactionPicker'; +import { ComposerReactionPicker } from '@/components/Reactions/ComposerReactionPicker' +import { SimpleNoteContent, SimpleNoteContentRef } from '@/components/SimpleNoteEditor/SimpleNoteContent' +import { useScope } from '@/contexts/scope' +import { usePostIssueSubmit } from '@/hooks/issues/usePostIssueSubmit' +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 { trimHtml } from '@/utils/trimHtml' +import { MemberAvatar } from '../MemberAvatar' +import { tags } from './utils/consts' +import { extractTextArray } from './utils/extractText' +import { pickWithReflect } from './utils/pickWithReflectDeep' + +// import { OrganizationMember } from '@gitmono/types/generated' + export default function IssueNewPage() { + const { scope } = useScope() const [title, setTitle] = useState('') const [loadings, setLoadings] = useState([]) const router = useRouter() const { mutate: submitNewIssue } = usePostIssueSubmit() + 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.remarks, + leadingVisual: () => ( +
+ ) + })), + [] + ) + const [isReactionPickerOpen, setIsReactionPickerOpen] = useState(false) const set_to_loading = (index: number) => { setLoadings((prevLoadings) => { @@ -39,32 +105,29 @@ export default function IssueNewPage() { }) } - const submit = useCallback( - () => { - const currentContentHTML = editorRef.current?.editor?.getHTML() ?? '

'; + const submit = useCallback(() => { + const currentContentHTML = editorRef.current?.editor?.getHTML() ?? '

' - if (trimHtml(currentContentHTML) === '' || !title) { - toast.error('please fill the issue list first!') - return + if (trimHtml(currentContentHTML) === '' || !title) { + toast.error('please fill the issue list first!') + return + } + set_to_loading(3) + submitNewIssue( + { data: { title, description: currentContentHTML } }, + { + onSuccess: (_response) => { + editorRef.current?.clearAndBlur() + cancel_loading(3) + toast.success('success') + router.push(`/${router.query.org}/issue`) + }, + onError: () => apiErrorToast } - set_to_loading(3) - submitNewIssue( - { data: { title, description: currentContentHTML } }, - { - onSuccess: (_response) => { - editorRef.current?.clearAndBlur() - cancel_loading(3) - toast.success('success') - router.push(`/${router.query.org}/issue`) - }, - onError: () => apiErrorToast - } - ) - }, - [router, title, submitNewIssue] - ) + ) + }, [router, title, submitNewIssue]) - const editorRef = useRef(null); + const editorRef = useRef(null) const onKeyDownScrollHandler = useHandleBottomScrollOffset({ editor: editorRef.current?.editor }) @@ -72,60 +135,247 @@ export default function IssueNewPage() { upload: editorRef.current?.uploadAndAppendAttachments }) + 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.remarks, i) + }) + return map + }, []) + + // const createGroup = (selected: ItemInput[]) => { + // setAvatarItems((prev) => { + // return mergeAndDeduplicate(prev, selected, 'end', '1') + // }) + // } + + // const groupMetadata: GroupedListProps['groupMetadata'] = [ + // { groupId: '1', header: { title: 'Group assignees', variant: 'filled' } }, + // { groupId: '2', header: { title: 'Items', variant: 'filled' } }, + // { groupId: 'end', header: { title: 'Suggestions', variant: 'filled' } } + // ] + return ( <> -
- Add a title - -

- Add a title - setTitle(e.target.value)} - > -

-
- -
-

Add a comment

- -
- - - - + +
+
+ + Add a title + setTitle(e.target.value)} + className='w-full' + /> + + + Add a description +
+ +
+
+ + + + +
+
+
+ {/* */} +
+ + {(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} +
+
+ ) + })} +
+ + ) + }} +
+ + + +
+
+ +
+ + ) +} + +type SelectPanelExcludedProps = + | 'open' + | 'onOpenChange' + | 'items' + | 'selected' + | 'onSelectedChange' + | 'onFilterChange' + | 'renderAnchor' + | 'variant' + +const BadgeItem = ({ + title, + selectPannelProps, + items, + children, + handleGroup +}: { + title: string + selectPannelProps?: Omit, SelectPanelExcludedProps> & { + variant?: 'anchored' + } + items: ItemInput[] + children?: (el: React.ReactNode) => React.ReactNode + handleGroup?: (selected: ItemInput[]) => void +}) => { + const [control, setControl] = useState(false) + + const [chose, setChose] = useState([]) + + const [filter, setFilter] = React.useState('') + + const filteredItems = items.filter( + (item) => + // design guidelines say to always show selected item in the list + chose.some((selectedItem) => selectedItem.text === item.text) || + // then filter the rest + item.text?.toLowerCase().startsWith(filter.toLowerCase()) + ) + + return ( + <> +
+ + { + return ( +
+ +
+ {title} + +
+
+ {container ? children?.(container) : } +
+ ) + }} + open={control} + onOpenChange={setControl} + items={filteredItems} + selected={chose} + onSelectedChange={(selected: ItemInput[]) => { + setChose(selected) + handleGroup?.(selected) + }} + onFilterChange={setFilter} + {...selectPannelProps} + >
+
+
+ + ) +} + +const SideBarItem = ({ emptyState }: { emptyState: string }) => { + return ( + <> +
+
{emptyState}
+
) diff --git a/moon/apps/web/components/Issues/IssuesContent.tsx b/moon/apps/web/components/Issues/IssuesContent.tsx index ef2406a62..3fdf753f5 100644 --- a/moon/apps/web/components/Issues/IssuesContent.tsx +++ b/moon/apps/web/components/Issues/IssuesContent.tsx @@ -4,7 +4,14 @@ import { formatDistance, fromUnixTime } from 'date-fns' import { useAtom } from 'jotai' import { SyncOrganizationMember as Member, PostApiIssueListData } from '@gitmono/types/generated' -import { Button, ChatBubbleIcon, CheckCircleFilledFlushIcon, ChevronDownIcon, OrderedListIcon } from '@gitmono/ui' +import { + Button, + ChatBubbleIcon, + CheckCircleFilledFlushIcon, + ChevronDownIcon, + CircleFilledCloseIcon, + OrderedListIcon +} from '@gitmono/ui' import { Link } from '@gitmono/ui/Link' // import { MenuItem } from '@gitmono/ui/Menu' @@ -285,6 +292,19 @@ export function IssuesContent({ searching }: Props) { return } + const getStatusIcon = (status: string) => { + const normalizedStatus = status.toLowerCase() + + switch (normalizedStatus) { + case 'open': + return + case 'closed': + return + default: + return null + } + } + return ( <> {/* TODO:Searching logic need to be completed */} @@ -315,7 +335,7 @@ export function IssuesContent({ searching }: Props) { } + leftIcon={getStatusIcon(i.status)} rightIcon={} >
diff --git a/moon/apps/web/components/Issues/utils/extractText.ts b/moon/apps/web/components/Issues/utils/extractText.ts new file mode 100644 index 000000000..4848e1e4f --- /dev/null +++ b/moon/apps/web/components/Issues/utils/extractText.ts @@ -0,0 +1,20 @@ +import React from 'react' + +export function extractTextArray(node: React.ReactNode): string[] { + if (typeof node === 'string' || typeof node === 'number') { + return [node.toString()] + } + + if (Array.isArray(node)) { + // 递归展开所有节点,返回扁平化的字符串数组 + return node.flatMap(extractTextArray) + } + + if (React.isValidElement(node)) { + // 递归提取 React 元素的 children + return extractTextArray(node.props.children) + } + + // 其他情况(null, undefined, boolean, function) + return [] +} diff --git a/moon/apps/web/components/Issues/utils/mergeDuplicate.ts b/moon/apps/web/components/Issues/utils/mergeDuplicate.ts new file mode 100644 index 000000000..f39ef33b1 --- /dev/null +++ b/moon/apps/web/components/Issues/utils/mergeDuplicate.ts @@ -0,0 +1,22 @@ +import { ItemInput } from '@primer/react/lib/deprecated/ActionList' + +export function mergeAndDeduplicate( + prev: ItemInput[], + selected: ItemInput[], + prevId: string, + selectId: string +): ItemInput[] { + const updatedPrev = prev.map((item) => ({ + ...item, + groupId: prevId + })) + + const updatedSelected = selected.map((item) => ({ + ...item, + groupId: selectId + })) + + const mergedArray = [...updatedPrev, ...updatedSelected] + + return Array.from(new Map(mergedArray.map((item) => [item.text, item])).values()) +} diff --git a/moon/apps/web/components/Issues/utils/pickWithReflectDeep.ts b/moon/apps/web/components/Issues/utils/pickWithReflectDeep.ts new file mode 100644 index 000000000..9ae182256 --- /dev/null +++ b/moon/apps/web/components/Issues/utils/pickWithReflectDeep.ts @@ -0,0 +1,8 @@ +export const pickWithReflect = (obj: T, keys: K[]): Pick => { + const result = {} as Pick + + keys.forEach((i) => { + result[i] = Reflect.get(obj, i) + }) + return result +} diff --git a/moon/apps/web/components/MrView/index.tsx b/moon/apps/web/components/MrView/index.tsx index aa5d8e354..f2250762d 100644 --- a/moon/apps/web/components/MrView/index.tsx +++ b/moon/apps/web/components/MrView/index.tsx @@ -4,19 +4,38 @@ import React, { useCallback, useEffect, useState } from 'react' import { formatDistance, fromUnixTime } from 'date-fns' import { useAtom } from 'jotai' -import { ChatBubbleIcon, CheckCircleFilledFlushIcon, CircleFilledCloseIcon, ClockIcon } from '@gitmono/ui' +import { SyncOrganizationMember as Member } from '@gitmono/types/generated' +import { + Button, + ChatBubbleIcon, + CheckCircleFilledFlushIcon, + ChevronDownIcon, + CircleFilledCloseIcon, + ClockIcon +} from '@gitmono/ui' import { Link } from '@gitmono/ui/Link' import { cn } from '@gitmono/ui/src/utils' import { IssueIndexTabFilter as MRIndexTabFilter } from '@/components/Issues/IssueIndex' -import { ListBanner, ListItem as MrItem, IssueList as MrList } from '@/components/Issues/IssueList' +import { + Dropdown, + DropdownItemwithAvatar, + DropdownItemwithLabel, + ListBanner, + ListItem as MrItem, + IssueList as MrList +} from '@/components/Issues/IssueList' import { useScope } from '@/contexts/scope' import { usePostMrList } from '@/hooks/usePostMrList' +import { useSyncedMembers } from '@/hooks/useSyncedMembers' import { apiErrorToast } from '@/utils/apiErrorToast' import { IndexPageContainer, IndexPageContent } from '../IndexPages/components' +import { Label } from '../Issues/IssuesContent' import { Pagination } from '../Issues/Pagenation' -import { filterAtom } from '../Issues/utils/store' +import { tags } from '../Issues/utils/consts' +import { generateAllMenuItems, MenuConfig } from '../Issues/utils/generateAllMenuItems' +import { filterAtom, sortAtom } from '../Issues/utils/store' import { Heading } from './catalyst/heading' interface MrInfoItem { @@ -95,7 +114,7 @@ export default function MrView() { switch (normalizedStatus) { case 'open': - return + return case 'closed': return case 'merged': @@ -124,6 +143,144 @@ export default function MrView() { } } + const [sort, setSort] = useAtom(sortAtom({ scope, filter: 'sortPickerMR' })) + const { members } = useSyncedMembers() + + const MemberConfig: MenuConfig[] = [ + { + key: 'Author', + isChosen: (item) => item.user.id === sort['Author'], + onSelectFactory: (item: Member) => (e: Event) => { + e.preventDefault() + if (item.user.id === sort['Author']) { + loadMrList() + setSort({ + ...sort, + Author: '' + }) + } else { + setMrList(mrList.filter((i) => i.link === sort['Author'])) + setSort({ + ...sort, + Author: item.user.id + }) + } + }, + className: 'overflow-hidden', + labelFactory: (item: Member) => + }, + { + key: 'Assignees', + isChosen: (item: Member) => item.user.id === sort['Assignees'], + onSelectFactory: (item: Member) => (e: Event) => { + e.preventDefault() + if (item.user.id === sort['Assignees']) { + loadMrList() + + setSort({ + ...sort, + Assignees: '' + }) + } else { + setMrList(mrList.filter((i) => i.link === sort['Assignees'])) + setSort({ + ...sort, + Assignees: item.user.id + }) + } + }, + className: 'overflow-hidden', + labelFactory: (item: Member) => + } + ] + + const LabelConfig: MenuConfig