mirror of
https://github.com/social-tw/social-tw-website.git
synced 2026-01-08 23:18:05 -05:00
[FIX] [Frontend] fix action link and scroll (#425)
Fix action link, failed post, comment and add scroll
This commit is contained in:
@@ -2,10 +2,15 @@ import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { ActionType, addAction } from '@/features/core'
|
||||
import ActionNotification from './ActionNotification'
|
||||
import { TestWrapper } from '@/utils/test-helpers/wrapper'
|
||||
|
||||
describe('ActionNotification', () => {
|
||||
it('should display nothing if no actions', () => {
|
||||
render(<ActionNotification />)
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ActionNotification />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
const countButton = screen.queryByTestId('action-count-button')
|
||||
expect(countButton).toBeNull()
|
||||
@@ -19,7 +24,11 @@ describe('ActionNotification', () => {
|
||||
transactionHash: 'hash',
|
||||
}
|
||||
|
||||
render(<ActionNotification />)
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ActionNotification />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
addAction(ActionType.Post, postData)
|
||||
@@ -37,7 +46,11 @@ describe('ActionNotification', () => {
|
||||
transactionHash: 'hash',
|
||||
}
|
||||
|
||||
render(<ActionNotification />)
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ActionNotification />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
addAction(ActionType.Post, postData)
|
||||
|
||||
@@ -36,7 +36,7 @@ function getActionLink(action: Action) {
|
||||
if (action.status === ActionStatus.Success) {
|
||||
return `/posts/${action.data.postId}`
|
||||
} else {
|
||||
return '/'
|
||||
return `/?failedPostId=${action.id}`
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -133,17 +133,21 @@ export default function ActionNotification() {
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-full p-4">
|
||||
<Dialog.Panel
|
||||
className="relative w-full h-80 max-w-md overflow-hidden rounded-xl bg-black/90 px-7 pt-14 pb-7 drop-shadow-[0_0_30px_rgba(255,255,255,0.1)]"
|
||||
className="relative w-full max-w-md overflow-hidden rounded-xl bg-black/90 drop-shadow-[0_0_30px_rgba(255,255,255,0.1)]"
|
||||
data-testid="actions-dialog"
|
||||
>
|
||||
<button
|
||||
className="absolute top-4 right-4 btn btn-sm btn-circle btn-ghost text-white/90"
|
||||
type="submit"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
<ActionTable />
|
||||
<div className="sticky top-0 z-10 bg-black/90 px-7 py-4">
|
||||
<button
|
||||
className="absolute top-4 right-4 btn btn-sm btn-circle btn-ghost text-white/90"
|
||||
type="submit"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-72 overflow-y-auto px-7 pb-7">
|
||||
<ActionTable onClose={() => setIsOpen(false)} />
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { ActionType, addAction } from '@/features/core'
|
||||
import ActionTable from './ActionTable'
|
||||
import * as router from 'react-router'
|
||||
import { TestWrapper } from '@/utils/test-helpers/wrapper'
|
||||
import { useState } from 'react'
|
||||
|
||||
const ActionTableWrapper = () => {
|
||||
const [, setIsOpen] = useState(false)
|
||||
return <ActionTable onClose={() => setIsOpen(false)} />
|
||||
}
|
||||
|
||||
describe('ActionTable', () => {
|
||||
const mockedUsedNavigate = jest.fn()
|
||||
jest.spyOn(router, 'useNavigate').mockImplementation(
|
||||
() => mockedUsedNavigate,
|
||||
)
|
||||
|
||||
it('should display action list', () => {
|
||||
render(<ActionTable />)
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ActionTableWrapper />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
const postData = {
|
||||
id: 'post-id-1',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
@@ -28,7 +28,7 @@ function getActionLink(action: Action) {
|
||||
if (action.status === ActionStatus.Success) {
|
||||
return `/posts/${action.data.postId}`
|
||||
} else {
|
||||
return '/'
|
||||
return `/?failedPostId=${action.id}`
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -53,15 +53,32 @@ function getActionStatusLabel(status: ActionStatus) {
|
||||
return actionStatusLabels[status]
|
||||
}
|
||||
|
||||
function ActionLink({ action }: { action: Action }) {
|
||||
function ActionLink({
|
||||
action,
|
||||
onClose,
|
||||
}: {
|
||||
action: Action
|
||||
onClose: () => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const link = getActionLink(action)
|
||||
|
||||
if (action.status === ActionStatus.Pending) {
|
||||
return <span className="text-white">請稍候</span>
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
navigate(link)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className="underline text-secondary" to={link}>
|
||||
<Link
|
||||
className="underline text-secondary"
|
||||
to={link}
|
||||
onClick={handleClick}
|
||||
>
|
||||
前往查看
|
||||
</Link>
|
||||
)
|
||||
@@ -69,7 +86,7 @@ function ActionLink({ action }: { action: Action }) {
|
||||
|
||||
const columnHelper = createColumnHelper<Action>()
|
||||
|
||||
const columns = [
|
||||
const columns = (onClose: () => void) => [
|
||||
columnHelper.accessor('submittedAt', {
|
||||
header: 'Time',
|
||||
cell: (info) => dayjs(info.getValue()).format('HH:mm:ss'),
|
||||
@@ -85,23 +102,25 @@ const columns = [
|
||||
columnHelper.display({
|
||||
id: 'link',
|
||||
header: 'Link',
|
||||
cell: (props) => <ActionLink action={props.row.original} />,
|
||||
cell: (props) => (
|
||||
<ActionLink action={props.row.original} onClose={onClose} />
|
||||
),
|
||||
}),
|
||||
]
|
||||
|
||||
export default function ActionTable() {
|
||||
export default function ActionTable({ onClose }: { onClose: () => void }) {
|
||||
const data = useActionStore(actionsSelector)
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
columns: columns(onClose),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-auto">
|
||||
<div className="h-64 overflow-y-auto">
|
||||
<table className="w-full table-auto">
|
||||
<thead>
|
||||
<thead className="sticky top-0 bg-black/90">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
@@ -137,22 +156,6 @@ export default function ActionTable() {
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{table.getFooterGroups().map((footerGroup) => (
|
||||
<tr key={footerGroup.id}>
|
||||
{footerGroup.headers.map((header) => (
|
||||
<th key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.footer,
|
||||
header.getContext(),
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -189,6 +189,15 @@ export function failActionById(
|
||||
})
|
||||
}
|
||||
|
||||
export function removeAction(id: string) {
|
||||
useActionStore.setState((state) => {
|
||||
delete state.entities[id]
|
||||
const index = state.list.findIndex((itemId) => itemId === id)
|
||||
if (index !== -1) state.list.splice(index, 1)
|
||||
if (state.latestId === id) state.latestId = undefined
|
||||
})
|
||||
}
|
||||
|
||||
export function removeActionById(id: string) {
|
||||
useActionStore.setState((state) => {
|
||||
delete state.entities[id]
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen } from '@/utils/test-helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import CommentFormDesktop from './CommentFormDesktop'
|
||||
import { waitFor } from '@testing-library/react'
|
||||
|
||||
describe('CommentFormDesktop', () => {
|
||||
it('renders when open', async () => {
|
||||
render(<CommentFormDesktop isOpen={true} />)
|
||||
render(
|
||||
// eslint-disable-next-line react/jsx-no-undef
|
||||
<CommentFormDesktop isOpen={true} />,
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('comment editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from '@jest/globals'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import PostForm from './PostForm'
|
||||
|
||||
jest.mock('@uidotdev/usehooks', () => ({
|
||||
@@ -10,7 +11,11 @@ test('<PostForm /> should render and handle interactions', () => {
|
||||
const mockOnCancel = jest.fn()
|
||||
const mockOnSubmit = jest.fn()
|
||||
|
||||
render(<PostForm onCancel={mockOnCancel} onSubmit={mockOnSubmit} />)
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PostForm onCancel={mockOnCancel} onSubmit={mockOnSubmit} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
// @ts-ignore
|
||||
expect(screen.getByTitle('cancel a post')).toBeInTheDocument()
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { clsx } from 'clsx'
|
||||
import { useEffect } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
removeAction,
|
||||
useActionStore,
|
||||
ActionType,
|
||||
ActionStatus,
|
||||
} from '@/features/core'
|
||||
import { RichTextEditor } from '@/features/shared'
|
||||
import { clsx } from 'clsx'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export interface PostFormValues {
|
||||
content: string
|
||||
@@ -14,37 +21,59 @@ export default function PostForm({
|
||||
}: {
|
||||
onCancel?: () => void
|
||||
onSubmit?: (values: PostFormValues) => void
|
||||
onSubmitCancel?: () => void
|
||||
isSubmitCancellable?: boolean
|
||||
isSubmitCancelled?: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const { handleSubmit, control, reset, formState } = useForm<PostFormValues>(
|
||||
{
|
||||
defaultValues: {
|
||||
content: '',
|
||||
},
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const failedPostId = searchParams.get('failedPostId')
|
||||
const actions = useActionStore((state) => state.entities)
|
||||
|
||||
const { handleSubmit, control, reset, setValue } = useForm<PostFormValues>({
|
||||
defaultValues: {
|
||||
content: '',
|
||||
},
|
||||
)
|
||||
})
|
||||
useEffect(() => {
|
||||
if (failedPostId) {
|
||||
const failedPost = Object.values(actions).find(
|
||||
(action) =>
|
||||
action.type === ActionType.Post &&
|
||||
action.status === ActionStatus.Failure &&
|
||||
action.id === failedPostId,
|
||||
)
|
||||
if (failedPost && 'content' in failedPost.data) {
|
||||
setValue('content', failedPost.data.content)
|
||||
}
|
||||
}
|
||||
}, [failedPostId, actions, setValue])
|
||||
|
||||
const { isSubmitSuccessful } = formState
|
||||
|
||||
const _onSubmit = handleSubmit((values) => {
|
||||
const _onSubmit = handleSubmit(async (values) => {
|
||||
const cache = { ...values }
|
||||
reset({ content: '' })
|
||||
onSubmit(cache)
|
||||
try {
|
||||
onSubmit(cache)
|
||||
reset({ content: '' })
|
||||
if (failedPostId) {
|
||||
removeAction(failedPostId)
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to submit post:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const _onCancel = () => {
|
||||
reset({ content: '' })
|
||||
if (failedPostId) {
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
onCancel()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitSuccessful) {
|
||||
reset({ content: '' })
|
||||
const handleClearFailedPost = () => {
|
||||
if (failedPostId) {
|
||||
removeAction(failedPostId)
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
}, [isSubmitSuccessful, reset])
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -87,6 +116,7 @@ export default function PostForm({
|
||||
'min-h-[3rem] overflow-auto text-white text-xl tracking-wide',
|
||||
placeholder: 'text-gray-300 text-xl',
|
||||
}}
|
||||
onClearFailedPost={handleClearFailedPost}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,9 @@ import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
|
||||
import nodes from './nodes'
|
||||
import ClearAllPlugin from './plugins/ClearAllPlugin'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ActionStatus, ActionType, useActionStore } from '@/features/core'
|
||||
|
||||
const theme = {
|
||||
text: {
|
||||
@@ -35,6 +38,9 @@ export default function RichTextEditor({
|
||||
value,
|
||||
onValueChange,
|
||||
onError,
|
||||
failedPostContent,
|
||||
onFailedPostClear,
|
||||
onClearFailedPost,
|
||||
}: {
|
||||
namespace?: string
|
||||
classes?: {
|
||||
@@ -47,8 +53,54 @@ export default function RichTextEditor({
|
||||
value?: string
|
||||
onValueChange?: (md: string) => void
|
||||
onError?: (error: Error) => void
|
||||
failedPostContent?: string
|
||||
onFailedPostClear?: () => void
|
||||
onClearFailedPost?: () => void
|
||||
}) {
|
||||
const _editorState = () => $convertFromMarkdownString(value ?? '')
|
||||
let searchParams: URLSearchParams | null = null
|
||||
try {
|
||||
;[searchParams] = useSearchParams()
|
||||
} catch (e) {
|
||||
console.warn('RichTextEditor is not within a Router context')
|
||||
}
|
||||
const failedPostId = searchParams?.get('failedPostId') || null
|
||||
const [localFailedPostContent, setLocalFailedPostContent] = useState<
|
||||
string | undefined
|
||||
>(undefined)
|
||||
const actions = useActionStore((state) => state.entities)
|
||||
|
||||
useEffect(() => {
|
||||
if (failedPostId) {
|
||||
const failedPost = Object.values(actions).find(
|
||||
(action) =>
|
||||
action.type === ActionType.Post &&
|
||||
action.status === ActionStatus.Failure &&
|
||||
action.id === failedPostId,
|
||||
)
|
||||
if (failedPost && 'content' in failedPost.data) {
|
||||
setLocalFailedPostContent(failedPost.data.content)
|
||||
}
|
||||
}
|
||||
}, [failedPostId, actions])
|
||||
|
||||
useEffect(() => {
|
||||
if (localFailedPostContent) {
|
||||
onValueChange?.(localFailedPostContent)
|
||||
}
|
||||
}, [localFailedPostContent, onValueChange])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (localFailedPostContent && onClearFailedPost) {
|
||||
onClearFailedPost()
|
||||
}
|
||||
}
|
||||
}, [localFailedPostContent, onClearFailedPost])
|
||||
|
||||
const _editorState = () => {
|
||||
const content = localFailedPostContent ?? value ?? ''
|
||||
return $convertFromMarkdownString(content)
|
||||
}
|
||||
|
||||
const _onChange = (editorState: EditorState) => {
|
||||
editorState.read(() => {
|
||||
@@ -75,7 +127,10 @@ export default function RichTextEditor({
|
||||
|
||||
return (
|
||||
<div className={classes?.root}>
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<LexicalComposer
|
||||
initialConfig={initialConfig}
|
||||
key={localFailedPostContent}
|
||||
>
|
||||
{/* <ToolbarPlugin /> */}
|
||||
<div className="relative">
|
||||
<RichTextPlugin
|
||||
@@ -104,7 +159,14 @@ export default function RichTextEditor({
|
||||
<AutoFocusPlugin />
|
||||
<HistoryPlugin />
|
||||
<ClearEditorPlugin />
|
||||
<ClearAllPlugin value={value} />
|
||||
<ClearAllPlugin
|
||||
value={value}
|
||||
onClear={() => {
|
||||
onValueChange?.('')
|
||||
setLocalFailedPostContent(undefined)
|
||||
onFailedPostClear?.()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useIsFirstRender } from '@uidotdev/usehooks'
|
||||
|
||||
export default function ClearAllPlugin({
|
||||
value,
|
||||
onClear,
|
||||
}: {
|
||||
value?: string
|
||||
onClear?: () => void
|
||||
}): JSX.Element | null {
|
||||
const isFirstRender = useIsFirstRender()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
@@ -14,7 +16,8 @@ export default function ClearAllPlugin({
|
||||
if (!value && !isFirstRender) {
|
||||
editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined)
|
||||
editor.focus()
|
||||
onClear?.()
|
||||
}
|
||||
}, [value, isFirstRender, editor])
|
||||
}, [value, isFirstRender, editor, onClear])
|
||||
return null
|
||||
}
|
||||
|
||||
31
packages/frontend/src/utils/test-helpers/render.tsx
Normal file
31
packages/frontend/src/utils/test-helpers/render.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { render as rtlRender, RenderOptions } from '@testing-library/react'
|
||||
import { ReactElement } from 'react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||
route?: string
|
||||
useRouter?: boolean
|
||||
}
|
||||
|
||||
function render(
|
||||
ui: ReactElement,
|
||||
{
|
||||
route = '/',
|
||||
useRouter = true,
|
||||
...renderOptions
|
||||
}: CustomRenderOptions = {},
|
||||
) {
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
if (useRouter) {
|
||||
return (
|
||||
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
|
||||
)
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return rtlRender(ui, { wrapper: Wrapper, ...renderOptions })
|
||||
}
|
||||
|
||||
export * from '@testing-library/react'
|
||||
export { render }
|
||||
@@ -1,7 +1,12 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
interface TestWrapperProps {
|
||||
children: ReactNode
|
||||
initialEntries?: string[]
|
||||
}
|
||||
|
||||
export function wrapper({ children }: { children: ReactNode }) {
|
||||
const queryClient = new QueryClient()
|
||||
return (
|
||||
@@ -10,3 +15,14 @@ export function wrapper({ children }: { children: ReactNode }) {
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export const TestWrapper: React.FC<TestWrapperProps> = ({
|
||||
children,
|
||||
initialEntries = ['/'],
|
||||
}) => (
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<Routes>
|
||||
<Route path="*" element={children} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
"resolveJsonModule": true,
|
||||
"noImplicitAny": false,
|
||||
"declaration": true,
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["**/node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user