From dc72529df5349d23b6cb29b37fffdfddea8e3ff3 Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Wed, 24 Jun 2026 17:07:21 -0600 Subject: [PATCH 1/5] implement interactive dialog file upload --- app/actions/remote/file.test.ts | 38 ++ app/actions/remote/file.ts | 27 +- app/client/rest/files.test.ts | 10 + app/client/rest/files.ts | 8 + app/constants/apps.ts | 1 + app/screens/apps_form/apps_form_component.tsx | 31 +- app/screens/apps_form/apps_form_field.tsx | 30 + .../apps_form_file_field.test.tsx | 574 ++++++++++++++++++ .../apps_form_file_field.tsx | 456 ++++++++++++++ .../apps_form/apps_form_file_field/index.ts | 17 + app/screens/apps_form/index.tsx | 1 + .../dialog_router/dialog_router.test.tsx | 79 ++- app/screens/dialog_router/dialog_router.tsx | 41 +- app/screens/dialog_router/index.tsx | 12 +- app/utils/dialog_conversion.test.ts | 89 +++ app/utils/dialog_conversion.ts | 24 + app/utils/dialog_utils.ts | 5 + app/utils/integrations.test.ts | 33 + app/utils/integrations.ts | 8 + app/utils/interactive_dialog_adapter.test.ts | 55 +- app/utils/interactive_dialog_adapter.ts | 6 +- app/utils/navigation/index.tsx | 3 +- assets/base/i18n/en.json | 3 + types/api/apps.d.ts | 3 + types/api/integrations.d.ts | 4 +- 25 files changed, 1529 insertions(+), 29 deletions(-) create mode 100644 app/screens/apps_form/apps_form_file_field/apps_form_file_field.test.tsx create mode 100644 app/screens/apps_form/apps_form_file_field/apps_form_file_field.tsx create mode 100644 app/screens/apps_form/apps_form_file_field/index.ts diff --git a/app/actions/remote/file.test.ts b/app/actions/remote/file.test.ts index e77f59f745..8705da1ff0 100644 --- a/app/actions/remote/file.test.ts +++ b/app/actions/remote/file.test.ts @@ -9,6 +9,7 @@ import { downloadProfileImage, uploadFile, fetchPublicLink, + fetchFilesInfo, buildFileUrl, buildAbsoluteUrl, buildFilePreviewUrl, @@ -28,6 +29,7 @@ describe('actions/remote/file', () => { getProfilePictureUrl: jest.fn(), uploadAttachment: jest.fn(), getFilePublicLink: jest.fn(), + getFileInfo: jest.fn(), getFileUrl: jest.fn(), getAbsoluteUrl: jest.fn(), getFilePreviewUrl: jest.fn(), @@ -249,4 +251,40 @@ describe('actions/remote/file', () => { expect(buildFileThumbnailUrl(serverUrl, fileId)).toBe(''); }); }); + + describe('fetchFilesInfo', () => { + it('returns info for each requested file id, preserving order', async () => { + const fileA = {id: 'a', name: 'a.png'} as FileInfo; + const fileB = {id: 'b', name: 'b.png'} as FileInfo; + mockClient.getFileInfo. + mockResolvedValueOnce(fileA). + mockResolvedValueOnce(fileB); + + const result = await fetchFilesInfo(serverUrl, ['a', 'b']); + + expect(result.files).toEqual([fileA, fileB]); + expect(mockClient.getFileInfo).toHaveBeenCalledTimes(2); + }); + + it('skips files that fail to fetch (e.g. deleted) and keeps the rest', async () => { + const fileA = {id: 'a', name: 'a.png'} as FileInfo; + mockClient.getFileInfo. + mockResolvedValueOnce(fileA). + mockRejectedValueOnce(new Error('not found')); + + const result = await fetchFilesInfo(serverUrl, ['a', 'missing']); + + expect(result.files).toEqual([fileA]); + }); + + it('returns empty files when the client cannot be created', async () => { + (NetworkManager.getClient as jest.Mock).mockImplementationOnce(() => { + throw new Error('no client'); + }); + + const result = await fetchFilesInfo(serverUrl, ['a']); + + expect(result.files).toEqual([]); + }); + }); }); diff --git a/app/actions/remote/file.ts b/app/actions/remote/file.ts index 0a4fefb980..251715f157 100644 --- a/app/actions/remote/file.ts +++ b/app/actions/remote/file.ts @@ -4,7 +4,7 @@ import {DOWNLOAD_TIMEOUT} from '@constants/network'; import NetworkManager from '@managers/network_manager'; import {getFullErrorMessage} from '@utils/errors'; -import {logDebug} from '@utils/log'; +import {logDebug, logError} from '@utils/log'; import {forceLogoutIfNecessary} from './session'; @@ -40,6 +40,31 @@ export const uploadFile = ( } }; +export const fetchFilesInfo = async (serverUrl: string, fileIds: string[]): Promise<{files: FileInfo[]}> => { + try { + const client = NetworkManager.getClient(serverUrl); + + // Fetch each file independently so a single missing/deleted file + // (e.g. one cleared server-side) doesn't drop the rest. + const results = await Promise.allSettled(fileIds.map((id) => client.getFileInfo(id))); + const files: FileInfo[] = []; + results.forEach((result) => { + if (result.status === 'fulfilled' && result.value) { + files.push(result.value); + } else if (result.status === 'rejected') { + logDebug('error on fetchFilesInfo', getFullErrorMessage(result.reason)); + } + }); + + return {files}; + } catch (error) { + // Hard failure (e.g. client not registered) — louder than the per-file + // rejections above, which are expected 404s for deleted files. + logError('error on fetchFilesInfo', getFullErrorMessage(error)); + return {files: []}; + } +}; + export const fetchPublicLink = async (serverUrl: string, fileId: string) => { try { const client = NetworkManager.getClient(serverUrl); diff --git a/app/client/rest/files.test.ts b/app/client/rest/files.test.ts index bf4c6dda77..3544c4282c 100644 --- a/app/client/rest/files.test.ts +++ b/app/client/rest/files.test.ts @@ -69,6 +69,16 @@ test('getFilePublicLink', async () => { expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); }); +test('getFileInfo', async () => { + const fileId = 'file_id'; + const expectedUrl = `${client.getFileRoute(fileId)}/info`; + const expectedOptions = {method: 'get'}; + + await client.getFileInfo(fileId); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); +}); + test('uploadAttachment', () => { const file = {localPath: '/path/to/file'} as FileInfo; const channelId = 'channel_id'; diff --git a/app/client/rest/files.ts b/app/client/rest/files.ts index 6d8af553aa..553692cd3d 100644 --- a/app/client/rest/files.ts +++ b/app/client/rest/files.ts @@ -11,6 +11,7 @@ export interface ClientFilesMix { getFileThumbnailUrl: (fileId: string, timestamp: number) => string; getFilePreviewUrl: (fileId: string, timestamp: number) => string; getFilePublicLink: (fileId: string) => Promise<{link: string}>; + getFileInfo: (fileId: string) => Promise; uploadAttachment: ( file: FileInfo | ExtractedFileInfo, channelId: string, @@ -59,6 +60,13 @@ const ClientFiles = >(superclass: TBase) = ); }; + getFileInfo = async (fileId: string) => { + return this.doFetch( + `${this.getFileRoute(fileId)}/info`, + {method: 'get'}, + ); + }; + uploadAttachment = ( file: FileInfo | ExtractedFileInfo, channelId: string, diff --git a/app/constants/apps.ts b/app/constants/apps.ts index 0fdf047913..716afa08f8 100644 --- a/app/constants/apps.ts +++ b/app/constants/apps.ts @@ -36,6 +36,7 @@ export const AppFieldTypes: { [name: string]: AppFieldType } = { CHANNEL: 'channel', MARKDOWN: 'markdown', RADIO: 'radio', + FILE: 'file', }; export const SelectableAppFieldTypes = [ diff --git a/app/screens/apps_form/apps_form_component.tsx b/app/screens/apps_form/apps_form_component.tsx index 92756b0559..562a701990 100644 --- a/app/screens/apps_form/apps_form_component.tsx +++ b/app/screens/apps_form/apps_form_component.tsx @@ -93,6 +93,7 @@ export type Props = { refreshOnSelect: (field: AppField, values: AppFormValues, value: AppFormValue) => Promise>; submit: (values: AppFormValues) => Promise>; performLookupCall: (field: AppField, values: AppFormValues, value: AppFormValue) => Promise>; + channelId?: string; } type Errors = {[name: string]: string} @@ -136,10 +137,12 @@ function AppsFormComponent({ refreshOnSelect, submit, performLookupCall, + channelId = '', }: Props) { const scrollView = useRef(null); const isMountedRef = useRef(true); const [submitting, setSubmitting] = useState(false); + const [uploadingFields, setUploadingFieldsState] = useState>(new Set()); const navigation = useNavigation(); const intl = useIntl(); const serverUrl = useServerUrl(); @@ -149,6 +152,22 @@ function AppsFormComponent({ const theme = useTheme(); const style = getStyleFromTheme(theme); + const setFieldUploading = useCallback((fieldName: string, uploading: boolean) => { + setUploadingFieldsState((prev) => { + const has = prev.has(fieldName); + if (uploading === has) { + return prev; + } + const next = new Set(prev); + if (uploading) { + next.add(fieldName); + } else { + next.delete(fieldName); + } + return next; + }); + }, []); + useDidUpdate(() => { dispatchValues({elements: form.fields}); }, [form]); @@ -258,7 +277,7 @@ function AppsFormComponent({ ); const handleSubmit = useCallback(async (button?: string) => { - if (submitting) { + if (submitting || uploadingFields.size > 0) { return; } @@ -332,7 +351,7 @@ function AppsFormComponent({ })); setSubmitting(false); } - }, [elements, form, values, submit, submitting, updateErrors, serverUrl, intl]); + }, [elements, form, values, submit, submitting, uploadingFields, updateErrors, serverUrl, intl]); const performLookup = useCallback(async (name: string, userInput: string): Promise => { const field = form.fields?.find((f) => f.name === name); @@ -405,13 +424,13 @@ function AppsFormComponent({ headerRight: () => ( 0} testID='interactive_dialog.submit.button' text={form.submit_label || intl.formatMessage({id: 'interactive_dialog.submit', defaultMessage: 'Submit'})} /> ), }); - }, [form.submit_label, handleSubmit, intl, navigation, submitButtons, submitting]); + }, [form.submit_label, handleSubmit, intl, navigation, submitButtons, submitting, uploadingFields]); // Cleanup on unmount to prevent memory leaks useEffect(() => { @@ -462,6 +481,9 @@ function AppsFormComponent({ value={value || ''} performLookup={performLookup} onChange={onChange} + setFieldUploading={setFieldUploading} + channelId={channelId} + serverUrl={serverUrl} /> ); })} @@ -478,6 +500,7 @@ function AppsFormComponent({ theme={theme} size='lg' text={o.label || ''} + disabled={submitting || uploadingFields.size > 0} /> ))} diff --git a/app/screens/apps_form/apps_form_field.tsx b/app/screens/apps_form/apps_form_field.tsx index a8401b23d2..9b1d0b0514 100644 --- a/app/screens/apps_form/apps_form_field.tsx +++ b/app/screens/apps_form/apps_form_field.tsx @@ -16,6 +16,8 @@ import {isAppSelectOption} from '@utils/dialog_utils'; import {selectKeyboardType} from '@utils/integrations'; import {makeStyleSheetFromTheme} from '@utils/theme'; +import AppsFormFileField from './apps_form_file_field'; + const TEXT_DEFAULT_MAX_LENGTH = 150; const TEXTAREA_DEFAULT_MAX_LENGTH = 3000; @@ -26,6 +28,9 @@ export type Props = { value: AppFormValue; onChange: (name: string, value: AppFormValue) => void; performLookup: (name: string, userInput: string) => Promise; + setFieldUploading?: (fieldName: string, uploading: boolean) => void; + channelId?: string; + serverUrl?: string; } const dialogOptionToAppSelectOption = (option: DialogOption): AppSelectOption => ({ @@ -74,6 +79,9 @@ const AppsFormField = React.memo(({ value, onChange, performLookup, + setFieldUploading, + channelId, + serverUrl, }) => { const theme = useTheme(); const style = getStyleSheet(theme); @@ -86,6 +94,10 @@ const AppsFormField = React.memo(({ onChange(name, newValue); }, [name, onChange]); + const handlePendingChange = useCallback((hasPending: boolean) => { + setFieldUploading?.(name, hasPending); + }, [setFieldUploading, name]); + const handleSelect = useCallback((newValue: SelectedDialogOption) => { if (!newValue) { const emptyValue = field.multiselect ? [] : ''; @@ -246,6 +258,24 @@ const AppsFormField = React.memo(({ ); } + case AppFieldTypes.FILE: { + return ( + + ); + } } return null; diff --git a/app/screens/apps_form/apps_form_file_field/apps_form_file_field.test.tsx b/app/screens/apps_form/apps_form_file_field/apps_form_file_field.test.tsx new file mode 100644 index 0000000000..35ae395d43 --- /dev/null +++ b/app/screens/apps_form/apps_form_file_field/apps_form_file_field.test.tsx @@ -0,0 +1,574 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {act, fireEvent, waitFor} from '@testing-library/react-native'; +import React from 'react'; + +import {fetchFilesInfo, uploadFile} from '@actions/remote/file'; +import {renderWithIntlAndTheme} from '@test/intl-test-helper'; +import {openAttachmentOptions} from '@utils/navigation'; + +import AppsFormFileField from './apps_form_file_field'; + +import type {ClientResponse} from '@mattermost/react-native-network-client'; + +jest.mock('@actions/remote/file', () => ({ + uploadFile: jest.fn(), + fetchFilesInfo: jest.fn(), +})); + +jest.mock('@utils/navigation', () => ({ + openAttachmentOptions: jest.fn(), +})); + +jest.mock('@utils/file', () => ({ + uploadDisabledWarning: jest.fn(() => 'File uploads from mobile are disabled.'), +})); + +jest.mock('@utils/errors', () => ({ + getFullErrorMessage: jest.fn((e: unknown) => String(e)), +})); + +// Mock the shared upload-item + remove button to avoid deep-rendering their +// context/native dependencies (mirrors how the webapp test mocks FilePreview). +jest.mock('@components/upload_item_shared', () => { + const ReactModule = require('react'); + const {Text} = require('react-native'); + return { + __esModule: true, + default: (props: {testID?: string; file?: {name?: string}}) => + ReactModule.createElement(Text, {testID: props.testID}, props.file?.name), + }; +}); + +jest.mock('@components/upload_item_shared/remove_button', () => { + const ReactModule = require('react'); + const {Text} = require('react-native'); + return { + __esModule: true, + default: (props: {testID?: string; onPress?: () => void}) => + ReactModule.createElement(Text, {testID: props.testID, onPress: props.onPress}, 'remove'), + }; +}); + +const mockUploadFile = jest.mocked(uploadFile); +const mockFetchFilesInfo = jest.mocked(fetchFilesInfo); +const mockOpenAttachmentOptions = jest.mocked(openAttachmentOptions); + +const FILE_ID = 'file-id-1'; +const SERVER_URL = 'http://localhost:8065'; +const CHANNEL_ID = 'channel-id-1'; + +function makeExtractedFile(overrides: Partial = {}): ExtractedFileInfo { + return { + name: 'test.txt', + mime_type: 'text/plain', + size: 100, + localPath: '/local/path/test.txt', + clientId: 'stable-id-1', + extension: 'txt', + failed: false, + has_preview_image: false, + height: 0, + user_id: '', + width: 0, + ...overrides, + } as unknown as ExtractedFileInfo; +} + +function makeFileInfo(overrides: Partial = {}): FileInfo { + return { + id: 'existing-id-1', + name: 'existing.png', + extension: 'png', + mime_type: 'image/png', + size: 2048, + has_preview_image: true, + height: 100, + width: 100, + user_id: '', + ...overrides, + } as unknown as FileInfo; +} + +function makeSuccessResponse(fileId = FILE_ID): ClientResponse { + return { + code: 201, + data: { + file_infos: [{ + id: fileId, + name: 'test.txt', + extension: 'txt', + mime_type: 'text/plain', + size: 100, + has_preview_image: false, + height: 0, + width: 0, + user_id: '', + } as FileInfo], + }, + headers: {}, + ok: true, + redirectUrls: [], + retriesExhausted: false, + }; +} + +const mockOnChange = jest.fn(); +const mockOnPendingChange = jest.fn(); + +function getBaseProps(overrides: Partial> = {}) { + return { + name: 'file_field', + displayName: 'Attachment', + value: '', + onChange: mockOnChange, + onPendingChange: mockOnPendingChange, + allowMultiple: false, + canUploadFiles: true, + channelId: CHANNEL_ID, + serverUrl: SERVER_URL, + ...overrides, + }; +} + +// Helper to capture the onUploadFiles callback from the last openAttachmentOptions call. +// The picker hands back already-extracted file info (see PickerUtil/attachment_options). +function captureOnUploadFiles(): (files: ExtractedFileInfo[]) => void { + const lastCall = mockOpenAttachmentOptions.mock.calls[mockOpenAttachmentOptions.mock.calls.length - 1]; + return lastCall[0].onUploadFiles; +} + +describe('AppsFormFileField', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('choose button', () => { + it('renders a choose button', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + expect(getByTestId('file_field.choose.button')).toBeTruthy(); + }); + + it('renders "Choose Files" label when allowMultiple is true', () => { + const {getByText} = renderWithIntlAndTheme( + , + ); + expect(getByText('Choose Files')).toBeTruthy(); + }); + + it('calls openAttachmentOptions with canUploadFiles=true when pressed', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + fireEvent.press(getByTestId('file_field.choose.button')); + + expect(mockOpenAttachmentOptions).toHaveBeenCalledWith( + expect.objectContaining({ + canUploadFiles: true, + maxFilesReached: false, + }), + ); + }); + + it('is disabled when canUploadFiles is false', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + const btn = getByTestId('file_field.choose.button'); + expect(btn.props.accessibilityState?.disabled).toBe(true); + }); + + it('shows upload disabled warning when canUploadFiles is false', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + expect(getByTestId('file_field.upload.disabled.warning')).toBeTruthy(); + }); + }); + + describe('pre-population (hydration from value)', () => { + it('hydrates entries from existing file IDs on mount', async () => { + mockFetchFilesInfo.mockResolvedValue({files: [makeFileInfo({id: 'existing-1'})]}); + + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + await waitFor(() => { + expect(getByTestId('file_field.file.row.existing-1')).toBeTruthy(); + }); + expect(mockFetchFilesInfo).toHaveBeenCalledWith(SERVER_URL, ['existing-1']); + }); + + it('hydrates multiple comma-separated IDs', async () => { + mockFetchFilesInfo.mockResolvedValue({files: [ + makeFileInfo({id: 'existing-1', name: 'a.png'}), + makeFileInfo({id: 'existing-2', name: 'b.png'}), + ]}); + + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + await waitFor(() => { + expect(getByTestId('file_field.file.row.existing-1')).toBeTruthy(); + expect(getByTestId('file_field.file.row.existing-2')).toBeTruthy(); + }); + expect(mockFetchFilesInfo).toHaveBeenCalledWith(SERVER_URL, ['existing-1', 'existing-2']); + }); + + it('does not fetch or render anything when value is empty (cleared)', () => { + const {queryByTestId} = renderWithIntlAndTheme( + , + ); + expect(mockFetchFilesInfo).not.toHaveBeenCalled(); + expect(queryByTestId('file_field.file.row.existing-1')).toBeNull(); + }); + + it('does not echo onChange for pre-populated files', async () => { + mockFetchFilesInfo.mockResolvedValue({files: [makeFileInfo({id: 'existing-1'})]}); + + renderWithIntlAndTheme( + , + ); + + await waitFor(() => { + expect(mockFetchFilesInfo).toHaveBeenCalled(); + }); + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + it('does not clobber a user pick made while hydration is in flight', async () => { + // Defer the hydration fetch so we can interact before it resolves. + let resolveFetch: (v: {files: FileInfo[]}) => void = () => { /* set in executor */ }; + mockFetchFilesInfo.mockReturnValue(new Promise<{files: FileInfo[]}>((res) => { + resolveFetch = res; + })); + + let capturedOnComplete: ((r: ClientResponse) => void) | undefined; + mockUploadFile.mockImplementation((_url, _file, _ch, _prog, onComplete) => { + if (onComplete) { + capturedOnComplete = onComplete; + } + return {cancel: jest.fn()}; + }); + + const {getByTestId, queryByTestId} = renderWithIntlAndTheme( + , + ); + + // User picks + completes a file before the hydration fetch resolves. + fireEvent.press(getByTestId('file_field.choose.button')); + const onUploadFiles = captureOnUploadFiles(); + await act(async () => { + onUploadFiles([makeExtractedFile({clientId: 'user-pick', name: 'user.txt'})]); + }); + await act(async () => { + capturedOnComplete?.(makeSuccessResponse('user-file-id')); + }); + + // Late hydration resolves — it must NOT overwrite the user's pick. + await act(async () => { + resolveFetch({files: [makeFileInfo({id: 'existing-1'})]}); + }); + + expect(getByTestId('file_field.file.row.user-pick')).toBeTruthy(); + expect(queryByTestId('file_field.file.row.existing-1')).toBeNull(); + }); + }); + + describe('upload flow', () => { + it('starts upload after files are picked and calls onPendingChange(true)', async () => { + const extracted = makeExtractedFile(); + + let capturedOnComplete: ((r: ClientResponse) => void) | undefined; + mockUploadFile.mockImplementation((_url, _file, _ch, _prog, onComplete) => { + if (onComplete) { + capturedOnComplete = onComplete; + } + return {cancel: jest.fn()}; + }); + + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + fireEvent.press(getByTestId('file_field.choose.button')); + const onUploadFiles = captureOnUploadFiles(); + + await act(async () => { + onUploadFiles([extracted]); + }); + + expect(mockUploadFile).toHaveBeenCalledWith( + SERVER_URL, + expect.objectContaining({name: extracted.name}), + CHANNEL_ID, + expect.any(Function), + expect.any(Function), + expect.any(Function), + ); + expect(mockOnPendingChange).toHaveBeenCalledWith(true); + + // Complete the upload + await act(async () => { + capturedOnComplete?.(makeSuccessResponse()); + }); + + await waitFor(() => { + expect(mockOnPendingChange).toHaveBeenCalledWith(false); + expect(mockOnChange).toHaveBeenCalledWith('file_field', FILE_ID); + }); + }); + + it('calls onChange with joined IDs for multiple uploads', async () => { + const extracted1 = makeExtractedFile({clientId: 'stable-1', name: 'a.txt'}); + const extracted2 = makeExtractedFile({clientId: 'stable-2', name: 'b.txt'}); + + const completeCallbacks: Array<(r: ClientResponse) => void> = []; + mockUploadFile.mockImplementation((_url, _file, _ch, _prog, onComplete) => { + if (onComplete) { + completeCallbacks.push(onComplete); + } + return {cancel: jest.fn()}; + }); + + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + fireEvent.press(getByTestId('file_field.choose.button')); + const onUploadFiles = captureOnUploadFiles(); + + await act(async () => { + onUploadFiles([extracted1, extracted2]); + }); + + await act(async () => { + completeCallbacks[0]?.(makeSuccessResponse('id-a')); + completeCallbacks[1]?.(makeSuccessResponse('id-b')); + }); + + await waitFor(() => { + const lastCallValue = mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1]?.[1] ?? ''; + expect(lastCallValue).toMatch(/id-a|id-b/); + }); + }); + + it('shows a failed row when upload errors', async () => { + const extracted = makeExtractedFile(); + + let capturedOnError: ((r: {message: string}) => void) | undefined; + mockUploadFile.mockImplementation((_url, _file, _ch, _prog, _onComplete, onError) => { + if (onError) { + capturedOnError = onError; + } + return {cancel: jest.fn()}; + }); + + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + fireEvent.press(getByTestId('file_field.choose.button')); + const onUploadFiles = captureOnUploadFiles(); + + await act(async () => { + onUploadFiles([extracted]); + }); + + await act(async () => { + capturedOnError?.({message: 'Network error'}); + }); + + await waitFor(() => { + expect(getByTestId(`file_field.file.failed.${extracted.clientId}`)).toBeTruthy(); + }); + }); + + it('removes a file entry when remove button is pressed', async () => { + const extracted = makeExtractedFile(); + + let capturedOnComplete: ((r: ClientResponse) => void) | undefined; + mockUploadFile.mockImplementation((_url, _file, _ch, _prog, onComplete) => { + if (onComplete) { + capturedOnComplete = onComplete; + } + return {cancel: jest.fn()}; + }); + + const {getByTestId, queryByTestId} = renderWithIntlAndTheme( + , + ); + + fireEvent.press(getByTestId('file_field.choose.button')); + const onUploadFiles = captureOnUploadFiles(); + + await act(async () => { + onUploadFiles([extracted]); + }); + + await act(async () => { + capturedOnComplete?.(makeSuccessResponse()); + }); + + await waitFor(() => { + expect(getByTestId(`file_field.file.remove.${extracted.clientId}`)).toBeTruthy(); + }); + + fireEvent.press(getByTestId(`file_field.file.remove.${extracted.clientId}`)); + + await waitFor(() => { + expect(queryByTestId(`file_field.file.row.${extracted.clientId}`)).toBeNull(); + }); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenLastCalledWith('file_field', ''); + }); + }); + + it('aborts an in-flight upload when remove is pressed', async () => { + const extracted = makeExtractedFile(); + + const mockCancel = jest.fn(); + mockUploadFile.mockReturnValue({cancel: mockCancel}); + + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + fireEvent.press(getByTestId('file_field.choose.button')); + const onUploadFiles = captureOnUploadFiles(); + + await act(async () => { + onUploadFiles([extracted]); + }); + + await waitFor(() => { + expect(getByTestId(`file_field.file.row.${extracted.clientId}`)).toBeTruthy(); + }); + + fireEvent.press(getByTestId(`file_field.file.remove.${extracted.clientId}`)); + + expect(mockCancel).toHaveBeenCalledTimes(1); + }); + }); + + describe('allowMultiple behaviour', () => { + it('replaces files when allowMultiple is false and a second batch is picked', async () => { + const extracted1 = makeExtractedFile({clientId: 'stable-1', name: 'first.txt'}); + const extracted2 = makeExtractedFile({clientId: 'stable-2', name: 'second.txt'}); + + const completeCallbacks: Array<(r: ClientResponse) => void> = []; + mockUploadFile.mockImplementation((_url, _file, _ch, _prog, onComplete) => { + if (onComplete) { + completeCallbacks.push(onComplete); + } + return {cancel: jest.fn()}; + }); + + const {getByTestId, queryByTestId} = renderWithIntlAndTheme( + , + ); + + // First pick + fireEvent.press(getByTestId('file_field.choose.button')); + const onUploadFiles1 = captureOnUploadFiles(); + + await act(async () => { + onUploadFiles1([extracted1]); + }); + await act(async () => { + completeCallbacks[0]?.(makeSuccessResponse('id-1')); + }); + + await waitFor(() => { + expect(getByTestId(`file_field.file.row.${extracted1.clientId}`)).toBeTruthy(); + }); + + // Second pick — should replace + fireEvent.press(getByTestId('file_field.choose.button')); + const onUploadFiles2 = captureOnUploadFiles(); + + await act(async () => { + onUploadFiles2([extracted2]); + }); + await act(async () => { + completeCallbacks[1]?.(makeSuccessResponse('id-2')); + }); + + await waitFor(() => { + expect(queryByTestId(`file_field.file.row.${extracted1.clientId}`)).toBeNull(); + expect(getByTestId(`file_field.file.row.${extracted2.clientId}`)).toBeTruthy(); + }); + }); + + it('appends files when allowMultiple is true', async () => { + const extracted1 = makeExtractedFile({clientId: 'stable-1', name: 'first.txt'}); + const extracted2 = makeExtractedFile({clientId: 'stable-2', name: 'second.txt'}); + + const completeCallbacks: Array<(r: ClientResponse) => void> = []; + mockUploadFile.mockImplementation((_url, _file, _ch, _prog, onComplete) => { + if (onComplete) { + completeCallbacks.push(onComplete); + } + return {cancel: jest.fn()}; + }); + + const {getByTestId} = renderWithIntlAndTheme( + , + ); + + // First pick + fireEvent.press(getByTestId('file_field.choose.button')); + const onUploadFiles1 = captureOnUploadFiles(); + + await act(async () => { + onUploadFiles1([extracted1]); + }); + await act(async () => { + completeCallbacks[0]?.(makeSuccessResponse('id-1')); + }); + + await waitFor(() => { + expect(getByTestId(`file_field.file.row.${extracted1.clientId}`)).toBeTruthy(); + }); + + // Second pick — should append + fireEvent.press(getByTestId('file_field.choose.button')); + const onUploadFiles2 = captureOnUploadFiles(); + + await act(async () => { + onUploadFiles2([extracted2]); + }); + await act(async () => { + completeCallbacks[1]?.(makeSuccessResponse('id-2')); + }); + + await waitFor(() => { + expect(getByTestId(`file_field.file.row.${extracted1.clientId}`)).toBeTruthy(); + expect(getByTestId(`file_field.file.row.${extracted2.clientId}`)).toBeTruthy(); + }); + }); + }); + + describe('error and help text', () => { + it('shows errorText when provided', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + expect(getByTestId('file_field.error.text')).toBeTruthy(); + }); + + it('shows helpText when provided', () => { + const {getByTestId} = renderWithIntlAndTheme( + , + ); + expect(getByTestId('file_field.help.text')).toBeTruthy(); + }); + }); +}); diff --git a/app/screens/apps_form/apps_form_file_field/apps_form_file_field.tsx b/app/screens/apps_form/apps_form_file_field/apps_form_file_field.tsx new file mode 100644 index 0000000000..fcf7ef72c0 --- /dev/null +++ b/app/screens/apps_form/apps_form_file_field/apps_form_file_field.tsx @@ -0,0 +1,456 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {defineMessages, useIntl} from 'react-intl'; +import {Text, View} from 'react-native'; + +import {fetchFilesInfo, uploadFile} from '@actions/remote/file'; +import Button from '@components/button'; +import UploadItemShared, {type UploadItemFile} from '@components/upload_item_shared'; +import RemoveButton from '@components/upload_item_shared/remove_button'; +import {useTheme} from '@context/theme'; +import useDidMount from '@hooks/did_mount'; +import {usePreventDoubleTap} from '@hooks/utils'; +import {getFullErrorMessage} from '@utils/errors'; +import {uploadDisabledWarning} from '@utils/file'; +import {generateId} from '@utils/general'; +import {logDebug} from '@utils/log'; +import {openAttachmentOptions} from '@utils/navigation'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type {ClientResponse, ClientResponseError} from '@mattermost/react-native-network-client'; + +const messages = defineMessages({ + chooseFile: { + id: 'apps_form.file_field.choose_file', + defaultMessage: 'Choose File', + }, + chooseFiles: { + id: 'apps_form.file_field.choose_files', + defaultMessage: 'Choose Files', + }, + uploadFailed: { + id: 'apps_form.file_field.upload_failed', + defaultMessage: 'Upload failed', + }, +}); + +type CancelFn = () => void; + +type FileStatus = 'uploading' | 'uploaded' | 'failed'; + +type FileEntry = { + stableId: string; + name: string; + localPath: string; + mime_type: string; + size: number; + status: FileStatus; + progress: number; + fileId?: string; + extension?: string; + error?: string; +}; + +const uploadedEntryFromFileInfo = (file: FileInfo): FileEntry => ({ + stableId: file.id || generateId(), + name: file.name, + localPath: '', + mime_type: file.mime_type || '', + size: file.size || 0, + status: 'uploaded', + progress: 1, + fileId: file.id, + extension: file.extension, +}); + +const patchEntry = (entries: FileEntry[], stableId: string, patch: Partial): FileEntry[] => + entries.map((e) => (e.stableId === stableId ? {...e, ...patch} : e)); + +const removeEntryById = (entries: FileEntry[], stableId: string): FileEntry[] => + entries.filter((e) => e.stableId !== stableId); + +// Curried updaters keep the setEntries call sites flat (avoids deeply nested callbacks). +const patchEntryUpdater = (stableId: string, patch: Partial) => + (entries: FileEntry[]): FileEntry[] => patchEntry(entries, stableId, patch); + +const removeEntryUpdater = (stableId: string) => + (entries: FileEntry[]): FileEntry[] => removeEntryById(entries, stableId); + +export type Props = { + name: string; + displayName: string; + helpText?: string; + errorText?: string; + value: string; // comma-joined file IDs + onChange: (name: string, value: string) => void; + onPendingChange?: (hasPending: boolean) => void; + allowMultiple?: boolean; + readonly?: boolean; + canUploadFiles: boolean; + channelId: string; + serverUrl: string; + testID?: string; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + marginHorizontal: 15, + marginTop: 10, + marginBottom: 10, + }, + label: { + ...typography('Body', 100, 'SemiBold'), + color: theme.centerChannelColor, + marginBottom: 8, + }, + helpText: { + ...typography('Body', 75), + color: changeOpacity(theme.centerChannelColor, 0.64), + marginTop: 4, + }, + errorText: { + ...typography('Body', 75), + color: theme.errorTextColor, + marginTop: 4, + }, + warningText: { + ...typography('Body', 75), + color: theme.errorTextColor, + marginTop: 4, + }, + fileRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + position: 'relative', + }, + failedRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 6, + paddingHorizontal: 8, + paddingVertical: 6, + borderRadius: 4, + backgroundColor: changeOpacity(theme.errorTextColor, 0.08), + }, + failedText: { + ...typography('Body', 75), + color: theme.errorTextColor, + flex: 1, + }, + button: { + marginTop: 8, + alignSelf: 'flex-start', + }, +})); + +function AppsFormFileField({ + name, + displayName, + helpText, + errorText, + value, + onChange, + onPendingChange, + allowMultiple = false, + readonly = false, + canUploadFiles, + channelId, + serverUrl, + testID, +}: Props) { + const intl = useIntl(); + const theme = useTheme(); + const style = getStyleSheet(theme); + + const [entries, setEntries] = useState([]); + const cancelMapRef = useRef>(new Map()); + const isMountedRef = useRef(true); + const hasInteractedRef = useRef(false); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + useEffect(() => { + const cancelMap = cancelMapRef.current; + return () => { + isMountedRef.current = false; + for (const cancel of cancelMap.values()) { + cancel(); + } + cancelMap.clear(); + }; + }, []); + + // Pre-populate previews from an existing value (the file IDs the server/plugin + // sends back when re-opening the dialog). Mount-time only by design — re-opening + // mounts a fresh screen, and a cleared value (file-upload-clear) yields no entries. + useDidMount(() => { + const ids = value.split(',').map((id) => id.trim()).filter(Boolean); + if (!ids.length) { + return; + } + + (async () => { + const {files} = await fetchFilesInfo(serverUrl, ids); + + // Skip if the user picked/removed files while the fetch was in flight — + // don't clobber their changes with the late hydration result. + if (!isMountedRef.current || hasInteractedRef.current || !files.length) { + return; + } + + setEntries(files.map(uploadedEntryFromFileInfo)); + })().catch((error) => { + // fetchFilesInfo is designed not to throw, but guard the bare IIFE + // against an unhandled rejection if that ever changes. + logDebug('apps_form_file_field hydration failed', getFullErrorMessage(error)); + }); + }); + + // Notify parent the field isn't ready while an upload is in flight OR a failed + // entry is present, so the form submit stays blocked until failures are resolved. + const isUploading = entries.some((e) => e.status === 'uploading'); + const hasFailed = entries.some((e) => e.status === 'failed'); + useEffect(() => { + onPendingChange?.(isUploading || hasFailed); + }, [isUploading, hasFailed, onPendingChange]); + + // Push completed file IDs up to the parent form whenever entries settle + useEffect(() => { + if (entries.some((e) => e.status === 'uploading') || !hasInteractedRef.current) { + return; + } + const ids = entries.filter((e) => e.status === 'uploaded' && e.fileId).map((e) => e.fileId!); + onChangeRef.current(name, ids.join(',')); + }, [entries, name]); + + const startUpload = useCallback((entry: FileEntry) => { + const extractedFile: ExtractedFileInfo = { + name: entry.name, + mime_type: entry.mime_type, + size: entry.size, + localPath: entry.localPath, + clientId: entry.stableId, + } as ExtractedFileInfo; + + const onProgress = (fraction: number) => { + if (!isMountedRef.current) { + return; + } + setEntries(patchEntryUpdater(entry.stableId, {progress: fraction})); + }; + + const onComplete = (response: ClientResponse) => { + if (!isMountedRef.current) { + return; + } + cancelMapRef.current.delete(entry.stableId); + + if (response.code !== 201 || !response.data) { + const msg = (response.data?.message as string | undefined) || intl.formatMessage(messages.uploadFailed); + setEntries(patchEntryUpdater(entry.stableId, {status: 'failed', error: msg})); + return; + } + + const fileInfos = response.data.file_infos as FileInfo[] | undefined; + const fileInfo = fileInfos?.[0]; + if (!fileInfo?.id) { + const msg = intl.formatMessage(messages.uploadFailed); + setEntries(patchEntryUpdater(entry.stableId, {status: 'failed', error: msg})); + return; + } + + setEntries(patchEntryUpdater(entry.stableId, {status: 'uploaded', fileId: fileInfo.id, progress: 1})); + }; + + const onError = (response: ClientResponseError) => { + if (!isMountedRef.current) { + return; + } + cancelMapRef.current.delete(entry.stableId); + const msg = response.message || getFullErrorMessage(response) || intl.formatMessage(messages.uploadFailed); + setEntries(patchEntryUpdater(entry.stableId, {status: 'failed', error: msg})); + }; + + const result = uploadFile(serverUrl, extractedFile, channelId, onProgress, onComplete, onError); + if ('cancel' in result && result.cancel) { + cancelMapRef.current.set(entry.stableId, result.cancel); + } + }, [serverUrl, channelId, intl]); + + const handleUploadFiles = useCallback((files: ExtractedFileInfo[]) => { + if (readonly) { + return; + } + hasInteractedRef.current = true; + + // Single-file mode: only the first selection counts. + const incoming = allowMultiple ? files : files.slice(0, 1); + if (!incoming.length) { + return; + } + + const newEntries: FileEntry[] = incoming.map((f) => ({ + stableId: f.clientId || generateId(), + name: f.name, + localPath: f.localPath || '', + mime_type: f.mime_type, + size: f.size || 0, + status: 'uploading', + progress: 0, + })); + + if (!allowMultiple) { + // Single-file replace: cancel any in-flight upload from the previous + // pick so the superseded request doesn't keep running into a no-op. + // Done outside the setEntries updater to keep the updater pure. + for (const [, cancel] of cancelMapRef.current) { + cancel(); + } + cancelMapRef.current.clear(); + } + + setEntries((prev) => (allowMultiple ? [...prev, ...newEntries] : newEntries)); + + for (const entry of newEntries) { + startUpload(entry); + } + }, [allowMultiple, readonly, startUpload]); + + const handleChoosePress = useCallback(() => { + openAttachmentOptions({ + onUploadFiles: handleUploadFiles, + canUploadFiles, + + // Single-file fields cap the picker selection at 1. + maxFileCount: allowMultiple ? undefined : 1, + fileCount: entries.length, + maxFilesReached: !allowMultiple && entries.length > 0, + }); + }, [allowMultiple, entries.length, handleUploadFiles, canUploadFiles]); + + const onChoosePress = usePreventDoubleTap(handleChoosePress); + + const handleRemove = useCallback((stableId: string) => { + hasInteractedRef.current = true; + + const cancel = cancelMapRef.current.get(stableId); + if (cancel) { + cancel(); + cancelMapRef.current.delete(stableId); + } + + setEntries(removeEntryUpdater(stableId)); + }, []); + + const chooseBtnText = allowMultiple? intl.formatMessage(messages.chooseFiles): intl.formatMessage(messages.chooseFile); + + return ( + + {displayName} + + {entries.map((entry) => { + if (entry.status === 'failed') { + return ( + + + {entry.name}{': '}{entry.error} + + {!readonly && ( + handleRemove(entry.stableId)} + testID={`${name}.file.remove.${entry.stableId}`} + /> + )} + + ); + } + + const uploadItemFile: UploadItemFile = { + name: entry.name, + mime_type: entry.mime_type, + size: entry.size, + uri: entry.localPath, + clientId: entry.stableId, + id: entry.fileId, + extension: entry.extension, + }; + + return ( + + + {!readonly && ( + handleRemove(entry.stableId)} + testID={`${name}.file.remove.${entry.stableId}`} + /> + )} + + ); + })} + +