Repository: ant-design/ant-design-mobile (ts)

Issue:
ImageUploader 组件同时设置 maxCount 和 showFailed=false 上传按钮消失的问题
### Version of antd-mobile

5.20.0

### Operating system and its version

_No response_

### Browser and its version

_No response_

### Sandbox to reproduce

_No response_

### What happened?

// image-uploader.tsx 可以修改成以下代码：

```ts
} catch (e) {// 上传失败
  setTasks(prev => {
    if (!props.showFailed) {// fix: 不展示失败图片, 需要从 tasks 中去除
      return prev.filter(x => x.id !== currentTask.id)
    } else {// 展示失败图片, 置为 fail 状态
      return prev.map(task => {
        if (task.id === currentTask.id) {
          return {
            ...task,
            status: 'fail',
          }
        }
        return task
      })
    }
  })
  throw e
}
```
### Relevant log output

_No response_

Pull request:
fix(ImageUploader): remove failed task when showFailed is false
fix #5763

Patch:
diff --git a/src/components/image-uploader/image-uploader.tsx b/src/components/image-uploader/image-uploader.tsx
index b3db8d793..fb801dabc 100644
--- a/src/components/image-uploader/image-uploader.tsx
+++ b/src/components/image-uploader/image-uploader.tsx
@@ -110,6 +110,12 @@ export const ImageUploader: FC<ImageUploaderProps> = p => {
     return transformedFile
   }
 
+  function getFinalTasks(tasks: Task[]) {
+    return props.showFailed
+      ? tasks
+      : tasks.filter(task => task.status !== 'fail')
+  }
+
   async function onChange(e: React.ChangeEvent<HTMLInputElement>) {
     e.persist()
     const { files: rawFiles } = e.target
@@ -148,7 +154,7 @@ export const ImageUploader: FC<ImageUploaderProps> = p => {
         } as Task)
     )
 
-    setTasks(prev => [...prev, ...newTasks])
+    setTasks(prev => [...getFinalTasks(prev), ...newTasks])
 
     await Promise.all(
       newTasks.map(async currentTask => {
@@ -204,9 +210,11 @@ export const ImageUploader: FC<ImageUploaderProps> = p => {
     imageViewerHandlerRef.current?.close()
   })
 
+  const finalTasks = getFinalTasks(tasks)
+
   const showUpload =
     props.showUpload &&
-    (maxCount === 0 || value.length + tasks.length < maxCount)
+    (maxCount === 0 || value.length + finalTasks.length < maxCount)
 
   const renderImages = () => {
     return value.map((fileItem, index) => {
@@ -238,10 +246,7 @@ export const ImageUploader: FC<ImageUploaderProps> = p => {
     <div className={classPrefix}>
       <Space className={`${classPrefix}-space`} wrap block>
         {renderImages()}
-        {tasks.map(task => {
-          if (!props.showFailed && task.status === 'fail') {
-            return null
-          }
+        {finalTasks.map(task => {
           return (
             <PreviewItem
               key={task.id}
@@ -250,7 +255,7 @@ export const ImageUploader: FC<ImageUploaderProps> = p => {
               status={task.status}
               imageFit={props.imageFit}
               onDelete={() => {
-                setTasks(tasks.filter(x => x.id !== task.id))
+                setTasks(prev => prev.filter(x => x.id !== task.id))
               }}
             />
           )
diff --git a/src/index.ts b/src/index.ts
index c51eb939c..855f5e9de 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -3,85 +3,229 @@ import './global'
 export { setDefaultConfig } from './components/config-provider'
 
 export { default as ActionSheet } from './components/action-sheet'
+export type { ActionSheetProps } from './components/action-sheet'
 export { default as AutoCenter } from './components/auto-center'
+export type { AutoCenterProps } from './components/auto-center'
 export { default as Avatar } from './components/avatar'
+export type { AvatarProps } from './components/avatar'
 export { default as Badge } from './components/badge'
+export type { BadgeProps } from './components/badge'
 export { default as Button } from './components/button'
+export type { ButtonProps, ButtonRef } from './components/button'
 export { default as Calendar } from './components/calendar'
+export type { CalendarProps, CalendarRef } from './components/calendar'
 export { default as CapsuleTabs } from './components/capsule-tabs'
+export type {
+  CapsuleTabsProps,
+  CapsuleTabProps,
+} from './components/capsule-tabs'
 export { default as Card } from './components/card'
+export type { CardProps } from './components/card'
 export { default as CascadePicker } from './components/cascade-picker'
+export type {
+  CascadePickerProps,
+  CascadePickerOption,
+  CascadePickerRef,
+} from './components/cascade-picker'
 export { default as CascadePickerView } from './components/cascade-picker-view'
+export type { CascadePickerViewProps } from './components/cascade-picker-view'
 export { default as Cascader } from './components/cascader'
+export type { CascaderProps, CascaderRef } from './components/cascader'
 export { default as CascaderView } from './components/cascader-view'
+export type {
+  CascaderViewProps,
+  CascaderOption,
+} from './components/cascader-view'
 export { default as CenterPopup } from './components/center-popup'
+export type { CenterPopupProps } from './components/center-popup'
 export { default as CheckList } from './components/check-list'
+export type {
+  CheckListProps,
+  CheckListItemProps,
+} from './components/check-list'
 export { default as Checkbox } from './components/checkbox'
+export type {
+  CheckboxProps,
+  CheckboxGroupProps,
+  CheckboxRef,
+} from './components/checkbox'
 export { default as Collapse } from './components/collapse'
+export type { CollapseProps, CollapsePanelProps } from './components/collapse'
 export { default as ConfigProvider } from './components/config-provider'
+export type { ConfigProviderProps } from './components/config-provider'
 export { default as DatePicker } from './components/date-picker'
+export type { DatePickerProps, DatePickerRef } from './components/date-picker'
 export { default as DatePickerView } from './components/date-picker-view'
+export type { DatePickerViewProps } from './components/date-picker-view'
 export { default as Dialog } from './components/dialog'
+export type {
+  DialogProps,
+  DialogShowProps,
+  DialogAlertProps,
+  DialogConfirmProps,
+} from './components/dialog'
 export { default as Divider } from './components/divider'
+export type { DividerProps } from './components/divider'
 export { default as DotLoading } from './components/dot-loading'
+export type { DotLoadingProps } from './components/dot-loading'
 export { default as Dropdown } from './components/dropdown'
+export type {
+  DropdownProps,
+  DropdownItemProps,
+  DropdownRef,
+} from './components/dropdown'
 export { default as Ellipsis } from './components/ellipsis'
+export type { EllipsisProps } from './components/ellipsis'
 export { default as Empty } from './components/empty'
+export type { EmptyProps } from './components/empty'
 export { default as ErrorBlock } from './components/error-block'
+export type {
+  ErrorBlockProps,
+  ErrorBlockStatus,
+} from './components/error-block'
 export { default as FloatingBubble } from './components/floating-bubble'
+export type { FloatingBubbleProps } from './components/floating-bubble'
 export { default as FloatingPanel } from './components/floating-panel'
+export type {
+  FloatingPanelProps,
+  FloatingPanelRef,
+} from './components/floating-panel'
 export { default as Form } from './components/form'
+export type { FormProps, FormItemProps } from './components/form'
 export { default as Grid } from './components/grid'
+export type { GridProps, GridItemProps } from './components/grid'
 export { default as Image } from './components/image'
+export type { ImageProps } from './components/image'
 export { default as ImageUploader } from './components/image-uploader'
+export type {
+  ImageUploaderProps,
+  ImageUploadItem,
+} from './components/image-uploader'
 export { default as ImageViewer } from './components/image-viewer'
+export type {
+  ImageViewerProps,
+  MultiImageViewerProps,
+  MultiImageViewerRef,
+} from './components/image-viewer'
 export { default as IndexBar } from './components/index-bar'
+export type {
+  IndexBarProps,
+  IndexBarPanelProps,
+  IndexBarRef,
+} from './components/index-bar'
 export { default as InfiniteScroll } from './components/infinite-scroll'
+export type { InfiniteScrollProps } from './components/infinite-scroll'
 export { default as Input } from './components/input'
+export type { InputProps, InputRef } from './components/input'
 export { default as JumboTabs } from './components/jumbo-tabs'
+export type { JumboTabsProps, JumboTabProps } from './components/jumbo-tabs'
 export { default as List } from './components/list'
+export type { ListProps, ListItemProps, ListRef } from './components/list'
 export { default as Loading } from './components/loading'
+export type { LoadingProps } from './components/loading'
 export { default as Mask } from './components/mask'
+export type { MaskProps } from './components/mask'
 export { default as Modal } from './components/modal'
+export type {
+  ModalProps,
+  ModalShowProps,
+  ModalAlertProps,
+  ModalConfirmProps,
+} from './components/modal'
 export { default as NavBar } from './components/nav-bar'
+export type { NavBarProps } from './components/nav-bar'
 export { default as NoticeBar } from './components/notice-bar'
+export type { NoticeBarProps } from './components/notice-bar'
 export { default as NumberKeyboard } from './components/number-keyboard'
+export type { NumberKeyboardProps } from './components/number-keyboard'
 export { default as PageIndicator } from './components/page-indicator'
+export type { PageIndicatorProps } from './components/page-indicator'
 export { default as PasscodeInput } from './components/passcode-input'
+export type {
+  PasscodeInputProps,
+  PasscodeInputRef,
+} from './components/passcode-input'
 export { default as Picker } from './components/picker'
+export type { PickerProps, PickerRef } from './components/picker'
 export { default as PickerView } from './components/picker-view'
+export type { PickerViewProps } from './components/picker-view'
 export { default as Popover } from './components/popover'
+export type {
+  PopoverProps,
+  PopoverMenuProps,
+  PopoverRef,
+} from './components/popover'
 export { default as Popup } from './components/popup'
+export type { PopupProps } from './components/popup'
 export { default as ProgressBar } from './components/progress-bar'
+export type { ProgressBarProps } from './components/progress-bar'
 export { default as ProgressCircle } from './components/progress-circle'
+export type { ProgressCircleProps } from './components/progress-circle'
 export { default as PullToRefresh } from './components/pull-to-refresh'
+export type { PullToRefreshProps } from './components/pull-to-refresh'
 export { default as Radio } from './components/radio'
+export type { RadioProps, RadioGroupProps } from './components/radio'
 export { default as Rate } from './components/rate'
+export type { RateProps } from './components/rate'
 export { default as Result } from './components/result'
+export type { ResultProps } from './components/result'
 export { default as ResultPage } from './components/result-page'
+export type { ResultPageProps } from './components/result-page'
 export { default as SafeArea } from './components/safe-area'
+export type { SafeAreaProps } from './components/safe-area'
 export { default as ScrollMask } from './components/scroll-mask'
+export type { ScrollMaskProps } from './components/scroll-mask'
 export { default as SearchBar } from './components/search-bar'
+export type { SearchBarProps, SearchBarRef } from './components/search-bar'
 export { default as Selector } from './components/selector'
+export type { SelectorProps, SelectorOption } from './components/selector'
 export { default as SideBar } from './components/side-bar'
+export type { SideBarProps, SideBarItemProps } from './components/side-bar'
 export { default as Skeleton } from './components/skeleton'
+export type { SkeletonProps, SkeletonTitleProps } from './components/skeleton'
 export { default as Slider } from './components/slider'
+export type { SliderProps } from './components/slider'
 export { default as Space } from './components/space'
+export type { SpaceProps } from './components/space'
 export { default as SpinLoading } from './components/spin-loading'
-export { default as Stepper } from './components//stepper'
+export type { SpinLoadingProps } from './components/spin-loading'
+export { default as Stepper } from './components/stepper'
+export type { StepperProps } from './components/stepper'
 export { default as Steps } from './components/steps'
+export type { StepsProps, StepProps } from './components/steps'
 export { default as SwipeAction } from './components/swipe-action'
+export type {
+  SwipeActionProps,
+  SwipeActionRef,
+} from './components/swipe-action'
 export { default as Swiper } from './components/swiper'
+export type { SwiperProps, SwiperRef } from './components/swiper'
 export { default as Switch } from './components/switch'
+export type { SwitchProps } from './components/switch'
 export { default as TabBar } from './components/tab-bar'
+export type { TabBarProps, TabBarItemProps } from './components/tab-bar'
 export { default as Tabs } from './components/tabs'
+export type { TabsProps, TabProps } from './components/tabs'
 export { default as Tag } from './components/tag'
+export type { TagProps } from './components/tag'
 export { default as TextArea } from './components/text-area'
+export type { TextAreaProps, TextAreaRef } from './components/text-area'
 export { default as Toast } from './components/toast'
+export type { ToastShowProps } from './components/toast'
 export { default as TreeSelect } from './components/tree-select'
+export type {
+  TreeSelectProps,
+  TreeSelectOption,
+} from './components/tree-select'
 export { default as VirtualInput } from './components/virtual-input'
+export type {
+  VirtualInputProps,
+  VirtualInputRef,
+} from './components/virtual-input'
 export { default as WaterMark } from './components/water-mark'
+export type { WaterMarkProps } from './components/water-mark'
 export { default as Footer } from './components/footer'
+export type { FooterProps } from './components/footer'
 export { createErrorBlock } from './components/error-block'
 
 export { reduceMotion, restoreMotion } from './utils/reduce-and-restore-motion'


Test patch:
diff --git a/src/components/image-uploader/tests/image-uploader.test.tsx b/src/components/image-uploader/tests/image-uploader.test.tsx
index 3ef405c24..cd5a621fa 100644
--- a/src/components/image-uploader/tests/image-uploader.test.tsx
+++ b/src/components/image-uploader/tests/image-uploader.test.tsx
@@ -42,11 +42,21 @@ async function mockInputFile(file: File | File[] = mockImg) {
   return inputEl
 }
 
+const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
+
 describe('ImageUploader', () => {
   // jsdom does not support createObjectURL
   URL.createObjectURL = jest.fn(() => '')
   URL.revokeObjectURL = jest.fn(() => '')
 
+  afterEach(() => {
+    errSpy.mockReset()
+  })
+
+  afterAll(() => {
+    errSpy.mockRestore()
+  })
+
   const App = (props: any) => {
     const [fileList, setFileList] = useState<ImageUploadItem[]>([
       {
@@ -77,16 +87,6 @@ describe('ImageUploader', () => {
   })
 
   test('upload status', async () => {
-    const originConsoleError = console.error
-    let uploadFailCount = 0
-    console.error = jest.fn((...args: any[]) => {
-      if (args[0]?.toString().includes('Fail to upload')) {
-        uploadFailCount++
-      } else {
-        originConsoleError(...args)
-      }
-    })
-
     const { container } = render(<App upload={mockUploadFail} showUpload />)
 
     await act(async () => {
@@ -103,8 +103,7 @@ describe('ImageUploader', () => {
     })
 
     expect(container).toMatchSnapshot()
-
-    expect(uploadFailCount).toBe(1)
+    expect(errSpy).toBeCalled()
   })
 
   test('limit size', async () => {
@@ -226,4 +225,36 @@ describe('ImageUploader', () => {
       expect($$(`.${customClassName}`)[0]).toBeVisible()
     })
   })
+
+  // https://github.com/ant-design/ant-design-mobile/issues/5763
+  test('the count should not be increased after the failed upload when `showFailed` is false', async () => {
+    render(<App upload={mockUploadFail} maxCount={2} showFailed={false} />)
+    await act(async () => {
+      await mockInputFile()
+    })
+    await waitFor(() =>
+      expect($$(`.${classPrefix}-upload-button`)[0]).toBeInTheDocument()
+    )
+  })
+
+  test('auto remove failed task before upload when `showFailed` is false', async () => {
+    const fn = jest.fn()
+    render(
+      <App
+        upload={mockUploadFail}
+        showFailed={false}
+        onUploadQueueChange={fn}
+      />
+    )
+    await mockInputFile()
+    expect(fn).toBeCalled()
+
+    await sleep(400)
+    expect(fn.mock.lastCall[0].length).toBe(1)
+
+    await mockInputFile()
+
+    await sleep(400)
+    expect(fn.mock.lastCall[0].length).toBe(1)
+  })
 })