Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions app/actions/remote/file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
downloadProfileImage,
uploadFile,
fetchPublicLink,
fetchFilesInfo,
buildFileUrl,
buildAbsoluteUrl,
buildFilePreviewUrl,
Expand All @@ -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(),
Expand Down Expand Up @@ -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([]);
});
});
});
27 changes: 26 additions & 1 deletion app/actions/remote/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions app/client/rest/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
8 changes: 8 additions & 0 deletions app/client/rest/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileInfo>;
uploadAttachment: (
file: FileInfo | ExtractedFileInfo,
channelId: string,
Expand Down Expand Up @@ -59,6 +60,13 @@ const ClientFiles = <TBase extends Constructor<ClientBase>>(superclass: TBase) =
);
};

getFileInfo = async (fileId: string) => {
return this.doFetch(
`${this.getFileRoute(fileId)}/info`,
{method: 'get'},
);
};

uploadAttachment = (
file: FileInfo | ExtractedFileInfo,
channelId: string,
Expand Down
1 change: 1 addition & 0 deletions app/constants/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const AppFieldTypes: { [name: string]: AppFieldType } = {
CHANNEL: 'channel',
MARKDOWN: 'markdown',
RADIO: 'radio',
FILE: 'file',
};

export const SelectableAppFieldTypes = [
Expand Down
31 changes: 27 additions & 4 deletions app/screens/apps_form/apps_form_component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export type Props = {
refreshOnSelect: (field: AppField, values: AppFormValues, value: AppFormValue) => Promise<DoAppCallResult<FormResponseData>>;
submit: (values: AppFormValues) => Promise<DoAppCallResult<FormResponseData>>;
performLookupCall: (field: AppField, values: AppFormValues, value: AppFormValue) => Promise<DoAppCallResult<AppLookupResponse>>;
channelId?: string;
}

type Errors = {[name: string]: string}
Expand Down Expand Up @@ -136,10 +137,12 @@ function AppsFormComponent({
refreshOnSelect,
submit,
performLookupCall,
channelId = '',
}: Props) {
const scrollView = useRef<KeyboardAwareScrollViewRef>(null);
const isMountedRef = useRef(true);
const [submitting, setSubmitting] = useState(false);
const [uploadingFields, setUploadingFieldsState] = useState<Set<string>>(new Set());
const navigation = useNavigation();
const intl = useIntl();
const serverUrl = useServerUrl();
Expand All @@ -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]);
Expand Down Expand Up @@ -258,7 +277,7 @@ function AppsFormComponent({
);

const handleSubmit = useCallback(async (button?: string) => {
if (submitting) {
if (submitting || uploadingFields.size > 0) {
return;
}

Expand Down Expand Up @@ -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<AppSelectOption[]> => {
const field = form.fields?.find((f) => f.name === name);
Expand Down Expand Up @@ -405,13 +424,13 @@ function AppsFormComponent({
headerRight: () => (
<NavigationButton
onPress={handleSubmit}
disabled={submitting}
disabled={submitting || uploadingFields.size > 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(() => {
Expand Down Expand Up @@ -462,6 +481,9 @@ function AppsFormComponent({
value={value || ''}
performLookup={performLookup}
onChange={onChange}
setFieldUploading={setFieldUploading}
channelId={channelId}
serverUrl={serverUrl}
/>
);
})}
Expand All @@ -478,6 +500,7 @@ function AppsFormComponent({
theme={theme}
size='lg'
text={o.label || ''}
disabled={submitting || uploadingFields.size > 0}
/>
</View>
))}
Expand Down
30 changes: 30 additions & 0 deletions app/screens/apps_form/apps_form_field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -26,6 +28,9 @@ export type Props = {
value: AppFormValue;
onChange: (name: string, value: AppFormValue) => void;
performLookup: (name: string, userInput: string) => Promise<AppSelectOption[]>;
setFieldUploading?: (fieldName: string, uploading: boolean) => void;
channelId?: string;
serverUrl?: string;
}

const dialogOptionToAppSelectOption = (option: DialogOption): AppSelectOption => ({
Expand Down Expand Up @@ -74,6 +79,9 @@ const AppsFormField = React.memo<Props>(({
value,
onChange,
performLookup,
setFieldUploading,
channelId,
serverUrl,
}) => {
const theme = useTheme();
const style = getStyleSheet(theme);
Expand All @@ -86,6 +94,10 @@ const AppsFormField = React.memo<Props>(({
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 ? [] : '';
Expand Down Expand Up @@ -246,6 +258,24 @@ const AppsFormField = React.memo<Props>(({
</View>
);
}
case AppFieldTypes.FILE: {
return (
<AppsFormFileField
name={name}
displayName={displayName}
helpText={field.description}
errorText={errorText}
value={typeof value === 'string' ? value : ''}
onChange={onChange}
onPendingChange={handlePendingChange}
allowMultiple={field.allow_multiple}
readonly={Boolean(field.readonly)}
channelId={channelId || ''}
serverUrl={serverUrl || ''}
testID={testID}
/>
);
}
}

return null;
Expand Down
Loading
Loading