[FIX] [Frontend] fix action link and scroll (#425)

Fix action link, failed post, comment and add scroll
This commit is contained in:
MorrisLin
2024-07-12 15:41:33 +08:00
committed by GitHub
parent a21a08a27e
commit 443ca258f1
13 changed files with 271 additions and 70 deletions

View File

@@ -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)

View File

@@ -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>

View File

@@ -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',

View File

@@ -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>
)

View File

@@ -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]

View File

@@ -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()
})

View File

@@ -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()

View File

@@ -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}
/>
)}
/>

View File

@@ -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>

View File

@@ -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
}

View 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 }

View File

@@ -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>
)

View File

@@ -9,7 +9,11 @@
"resolveJsonModule": true,
"noImplicitAny": false,
"declaration": true,
"skipLibCheck": true
"skipLibCheck": true,
"baseUrl": "src",
"paths": {
"@/*": ["./*"]
}
},
"exclude": ["**/node_modules"]
}