diff --git a/app/actions/remote/apps.ts b/app/actions/remote/apps.ts index 9acbfbf86..167eaa87d 100644 --- a/app/actions/remote/apps.ts +++ b/app/actions/remote/apps.ts @@ -31,7 +31,7 @@ export async function handleBindingClick(serverUrl: string, binding id: 'apps.error.malformed_binding', defaultMessage: 'This binding is not properly formed. Contact the App developer.', }); - return {error: makeCallErrorResponse(errMsg)}; + return {error: makeCallErrorResponse(errMsg)}; } const res: AppCallResponse = { @@ -48,7 +48,7 @@ export async function handleBindingClick(serverUrl: string, binding id: 'apps.error.malformed_binding', defaultMessage: 'This binding is not properly formed. Contact the App developer.', }); - return {error: makeCallErrorResponse(errMsg)}; + return {error: makeCallErrorResponse(errMsg)}; } const callRequest = createCallRequest( @@ -83,7 +83,7 @@ export async function doAppSubmit(serverUrl: string, inCall: AppCal id: 'apps.error.responses.form.no_form', defaultMessage: 'Response type is `form`, but no valid form was included in response.', }); - return {error: makeCallErrorResponse(errMsg)}; + return {error: makeCallErrorResponse(errMsg)}; } cleanForm(res.form); @@ -96,7 +96,7 @@ export async function doAppSubmit(serverUrl: string, inCall: AppCal id: 'apps.error.responses.navigate.no_url', defaultMessage: 'Response type is `navigate`, but no url was included in response.', }); - return {error: makeCallErrorResponse(errMsg)}; + return {error: makeCallErrorResponse(errMsg)}; } return {data: res}; @@ -107,7 +107,7 @@ export async function doAppSubmit(serverUrl: string, inCall: AppCal }, { type: responseType, }); - return {error: makeCallErrorResponse(errMsg)}; + return {error: makeCallErrorResponse(errMsg)}; } } } catch (error) { @@ -116,7 +116,7 @@ export async function doAppSubmit(serverUrl: string, inCall: AppCal defaultMessage: 'Received an unexpected error.', }); logDebug('error on doAppSubmit', getFullErrorMessage(error)); - return {error: makeCallErrorResponse(errMsg)}; + return {error: makeCallErrorResponse(errMsg)}; } } @@ -135,7 +135,7 @@ export async function doAppFetchForm(serverUrl: string, call: AppCa id: 'apps.error.responses.form.no_form', defaultMessage: 'Response type is `form`, but no valid form was included in response.', }); - return {error: makeCallErrorResponse(errMsg)}; + return {error: makeCallErrorResponse(errMsg)}; } cleanForm(res.form); return {data: res}; @@ -144,7 +144,7 @@ export async function doAppFetchForm(serverUrl: string, call: AppCa id: 'apps.error.responses.unknown_type', defaultMessage: 'App response type not supported. Response type: {type}.', }, {type: responseType}); - return {error: makeCallErrorResponse(errMsg)}; + return {error: makeCallErrorResponse(errMsg)}; } } } catch (error) { @@ -153,7 +153,7 @@ export async function doAppFetchForm(serverUrl: string, call: AppCa defaultMessage: 'Received an unexpected error.', }); logDebug('error on doAppFetchForm', getFullErrorMessage(error)); - return {error: makeCallErrorResponse(errMsg)}; + return {error: makeCallErrorResponse(errMsg)}; } } @@ -175,7 +175,7 @@ export async function doAppLookup(serverUrl: string, call: AppCallR id: 'apps.error.responses.unknown_type', defaultMessage: 'App response type not supported. Response type: {type}.', }, {type: responseType}); - return {error: makeCallErrorResponse(errMsg)}; + return {error: makeCallErrorResponse(errMsg)}; } } } catch (error: any) { @@ -184,7 +184,7 @@ export async function doAppLookup(serverUrl: string, call: AppCallR defaultMessage: 'Received an unexpected error.', }); logDebug('error on doAppLookup', getFullErrorMessage(error)); - return {error: makeCallErrorResponse(errMsg)}; + return {error: makeCallErrorResponse(errMsg)}; } } diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts index 5b8f1431c..2c1406992 100644 --- a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts @@ -112,7 +112,7 @@ export class ParsedCommand { }; private findBindings(b: AppBinding) { - return b.label.toLowerCase() === this.incomplete.toLowerCase(); + return b.label?.toLowerCase() === this.incomplete.toLowerCase(); } // matchBinding finds the closest matching command binding. @@ -321,7 +321,7 @@ export class ParsedCommand { let field = fields.find((f: AppField) => f.position === this.position); if (!field) { field = fields.find((f) => f.position === -1 && f.type === AppFieldTypes.TEXT); - if (!field || this.values[field.name]) { + if (!field?.name || this.values[field.name]) { return this.asError(this.intl.formatMessage({ id: 'apps.error.parser.no_argument_pos_x', defaultMessage: 'Unable to identify argument.', @@ -349,6 +349,13 @@ export class ParsedCommand { })); } + if (!this.field.name) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_field_name', + defaultMessage: 'Field name is missing.', + })); + } + if (autocompleteMode && c === '') { return this; } @@ -595,6 +602,13 @@ export class ParsedCommand { })); } + if (!this.field.name) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_field_name', + defaultMessage: 'Field name is missing.', + })); + } + // special handling for optional BOOL values ('--boolflag true' // vs '--boolflag next-positional' vs '--boolflag // --next-flag...') @@ -628,7 +642,14 @@ export class ParsedCommand { })); } - this.values![this.field.name] = []; + if (!this.field.name) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_field_name', + defaultMessage: 'Field name is missing.', + })); + } + + this.values![this.field.name] = []; switch (c) { case ' ': case '\t': @@ -784,6 +805,13 @@ export class ParsedCommand { })); } + if (!this.field.name) { + return this.asError(this.intl.formatMessage({ + id: 'apps.error.parser.missing_field_name', + defaultMessage: 'Field name is missing.', + })); + } + if (autocompleteMode && c === '') { return this; } @@ -961,7 +989,7 @@ export class AppCommandParser { } await Promise.all(parsed.resolvedForm?.fields.map(async (f) => { - if (!f.value) { + if (!f.value || !f.name) { return; } @@ -975,6 +1003,9 @@ export class AppCommandParser { break; case AppFieldTypes.USER: { const userID = (f.value as AppSelectOption).value; + if (!userID) { + return; + } let user: UserModel | UserProfile | undefined = await getUserById(this.database, userID); if (!user) { const res = await fetchUsersByIds(this.serverUrl, [userID]); @@ -992,6 +1023,9 @@ export class AppCommandParser { } case AppFieldTypes.CHANNEL: { const channelID = (f.value as AppSelectOption).label; + if (!channelID) { + return; + } let channel: ChannelModel | Channel | undefined = await getChannelById(this.database, channelID); if (!channel) { const res = await fetchChannelById(this.serverUrl, channelID); @@ -1008,9 +1042,14 @@ export class AppCommandParser { break; } case AppFieldTypes.STATIC_SELECT: - case AppFieldTypes.DYNAMIC_SELECT: - parsed.values[f.name] = (f.value as AppSelectOption).value; + case AppFieldTypes.DYNAMIC_SELECT: { + const optionValue = (f.value as AppSelectOption).value; + if (!optionValue) { + return; + } + parsed.values[f.name] = optionValue; break; + } case AppFieldTypes.MARKDOWN: // Do nothing @@ -1026,11 +1065,12 @@ export class AppCommandParser { const bindings = this.getCommandBindings(); for (const binding of bindings) { - let base = binding.label; - if (!base) { + if (!binding.label) { continue; } + let base = binding.label; + if (base[0] !== '/') { base = '/' + base; } @@ -1100,7 +1140,7 @@ export class AppCommandParser { ]; const call = parsed.resolvedForm?.submit || parsed.binding?.form?.submit; const hasRequired = this.getMissingFields(parsed).length === 0; - const hasValue = (parsed.state !== ParseState.EndValue || (parsed.field && parsed.values[parsed.field.name] !== undefined)); + const hasValue = (parsed.state !== ParseState.EndValue || (parsed.field && parsed.field.name && parsed.values[parsed.field.name] !== undefined)); if (executableStates.includes(parsed.state) && call && hasRequired && hasValue) { const execute = getExecuteSuggestion(parsed); @@ -1181,7 +1221,11 @@ export class AppCommandParser { const errors: {[key: string]: string} = {}; await Promise.all(parsed.resolvedForm.fields.map(async (f) => { - const fieldValue = values[f.name]; + const fieldName = f.name; + if (!fieldName) { + return; + } + const fieldValue = values[fieldName]; if (!fieldValue) { return; } @@ -1198,20 +1242,20 @@ export class AppCommandParser { const options: AppSelectOption[] = []; for (const value of commandValues) { if (options.find((o) => o.value === value)) { - errors[f.name] = this.intl.formatMessage({ + errors[fieldName] = this.intl.formatMessage({ id: 'apps.error.command.same_option', defaultMessage: 'Option repeated for field `{fieldName}`: `{option}`.', }, { - fieldName: f.name, + fieldName, option: value, }); } } - values[f.name] = options; + values[fieldName] = options; break; } - values[f.name] = {label: fieldValue, value: fieldValue}; + values[fieldName] = {label: fieldValue, value: fieldValue}; break; case AppFieldTypes.STATIC_SELECT: { const getOption = (value: string) => { @@ -1219,14 +1263,14 @@ export class AppCommandParser { }; const setOptionError = (value: string) => { - errors[f.name] = this.intl.formatMessage({ + errors[fieldName] = this.intl.formatMessage({ id: 'apps.error.command.unknown_option', defaultMessage: 'Unknown option for field `{fieldName}`: `{option}`.', }, { - fieldName: f.name, + fieldName, option: value, }); - values[f.name] = undefined; + values[fieldName] = undefined; }; if (f.multiselect) { @@ -1245,17 +1289,17 @@ export class AppCommandParser { return; } if (options.find((o) => o.value === option.value)) { - errors[f.name] = this.intl.formatMessage({ + errors[fieldName] = this.intl.formatMessage({ id: 'apps.error.command.same_option', defaultMessage: 'Option repeated for field `{fieldName}`: `{option}`.', }, { - fieldName: f.name, + fieldName, option: value, }); } options.push(option); } - values[f.name] = options; + values[fieldName] = options; break; } @@ -1264,7 +1308,7 @@ export class AppCommandParser { setOptionError(fieldValue); return; } - values[f.name] = option; + values[fieldName] = option; break; } case AppFieldTypes.USER: { @@ -1281,11 +1325,11 @@ export class AppCommandParser { }; const setUserError = (username: string) => { - errors[f.name] = this.intl.formatMessage({ + errors[fieldName] = this.intl.formatMessage({ id: 'apps.error.command.unknown_user', defaultMessage: 'Unknown user for field `{fieldName}`: `{option}`.', }, { - fieldName: f.name, + fieldName, option: username, }); }; @@ -1312,22 +1356,22 @@ export class AppCommandParser { } if (options.find((o) => o.value === user?.id)) { - errors[f.name] = this.intl.formatMessage({ + errors[fieldName] = this.intl.formatMessage({ id: 'apps.error.command.same_user', defaultMessage: 'User repeated for field `{fieldName}`: `{option}`.', }, { - fieldName: f.name, + fieldName, option: userName, }); } options.push({label: user.username, value: user.id}); } /* eslint-enable no-await-in-loop */ - values[f.name] = options; + values[fieldName] = options; break; } - let userName = values[f.name] as string; + let userName = values[fieldName] as string; if (userName[0] === '@') { userName = userName.substr(1); } @@ -1336,7 +1380,7 @@ export class AppCommandParser { setUserError(userName); return; } - values[f.name] = {label: user.username, value: user.id}; + values[fieldName] = {label: user.username, value: user.id}; break; } case AppFieldTypes.CHANNEL: { @@ -1353,11 +1397,11 @@ export class AppCommandParser { }; const setChannelError = (channelName: string) => { - errors[f.name] = this.intl.formatMessage({ + errors[fieldName] = this.intl.formatMessage({ id: 'apps.error.command.unknown_channel', defaultMessage: 'Unknown channel for field `{fieldName}`: `{option}`.', }, { - fieldName: f.name, + fieldName, option: channelName, }); }; @@ -1384,11 +1428,11 @@ export class AppCommandParser { } if (options.find((o) => o.value === channel.id)) { - errors[f.name] = this.intl.formatMessage({ + errors[fieldName] = this.intl.formatMessage({ id: 'apps.error.command.same_channel', defaultMessage: 'Channel repeated for field `{fieldName}`: `{option}`.', }, { - fieldName: f.name, + fieldName, option: channelName, }); } @@ -1397,11 +1441,11 @@ export class AppCommandParser { options.push({label, value: channel.id}); } /* eslint-enable no-await-in-loop */ - values[f.name] = options; + values[fieldName] = options; break; } - let channelName = values[f.name] as string; + let channelName = values[fieldName] as string; if (channelName[0] === '~') { channelName = channelName.substr(1); } @@ -1411,15 +1455,15 @@ export class AppCommandParser { return; } const label = 'display_name' in channel ? channel.display_name : channel.displayName; - values[f.name] = {label, value: channel.id}; + values[fieldName] = {label, value: channel.id}; break; } case AppFieldTypes.BOOL: { - const strValue = values[f.name] as string; + const strValue = values[fieldName] as string; if (strValue.toLowerCase() === 'true') { - values[f.name] = true; + values[fieldName] = true; } else { - values[f.name] = false; + values[fieldName] = false; } } } @@ -1501,7 +1545,7 @@ export class AppCommandParser { // getAppContext collects post/channel/team info for performing calls private getAppContext = async (binding: AppBinding): Promise => { const context: AppContext = { - app_id: binding.app_id, + app_id: binding.app_id!, // At this point is safe to assume we have an app_id location: binding.location, root_id: this.rootPostID, }; @@ -1591,7 +1635,7 @@ export class AppCommandParser { const result: AutocompleteSuggestion[] = []; bindings.forEach((b) => { - if (b.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase())) { + if (b.label?.toLowerCase().startsWith(parsed.incomplete.toLowerCase())) { result.push({ Complete: b.label, Suggestion: b.label, @@ -1690,7 +1734,7 @@ export class AppCommandParser { const values = parsed.values || []; const fields = form.fields || []; for (const field of fields) { - if (field.is_required && !values[field.name]) { + if (field.is_required && field.name && !values[field.name]) { // fields without names shouldn't be considered missing.push(field); } } @@ -1717,6 +1761,7 @@ export class AppCommandParser { const applicable = parsed.resolvedForm.fields.filter((field) => ( field.label && field.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase()) && + field.name && !parsed.values[field.name] && !field.readonly && field.type !== AppFieldTypes.MARKDOWN @@ -1780,7 +1825,7 @@ export class AppCommandParser { // getStaticSelectSuggestions returns suggestions specified in the field's options property private getStaticSelectSuggestions = (parsed: ParsedCommand, delimiter?: string): AutocompleteSuggestion[] => { const f = parsed.field as AutocompleteStaticSelect; - const opts = f.options?.filter((opt) => opt.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase())); + const opts = f.options?.filter((opt) => opt.label?.toLowerCase().startsWith(parsed.incomplete.toLowerCase())); if (!opts?.length) { return [{ Complete: '', @@ -1794,15 +1839,15 @@ export class AppCommandParser { }]; } return opts.map((opt) => { - let complete = opt.value; + let complete = opt.value || ''; if (delimiter) { complete = delimiter + complete + delimiter; - } else if (isMultiword(opt.value)) { + } else if (isMultiword(complete)) { complete = '`' + complete + '`'; } return { Complete: complete, - Suggestion: opt.label, + Suggestion: opt.label || '', Hint: f.hint || '', Description: f.description || '', IconData: opt.icon_data || parsed.binding?.icon || '', @@ -1883,16 +1928,16 @@ export class AppCommandParser { } return items.map((s): AutocompleteSuggestion => { - let complete = s.value; + let complete = s.value || ''; if (delimiter) { complete = delimiter + complete + delimiter; - } else if (isMultiword(s.value)) { + } else if (isMultiword(complete)) { complete = '`' + complete + '`'; } return ({ Complete: complete, - Description: s.label || s.value, - Suggestion: s.value, + Description: s.label || s.value || '', + Suggestion: s.value || '', Hint: '', IconData: s.icon_data || parsed.binding?.icon || '', }); diff --git a/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx b/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx index dafa0671e..0e1d8bb2d 100644 --- a/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx +++ b/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx @@ -70,7 +70,7 @@ const ButtonBinding = ({currentTeamId, binding, post, teamID, theme}: Props) => pressed.current = true; const context = createCallContext( - binding.app_id, + binding.app_id!, AppBindingLocations.IN_POST + binding.location, post.channelId, teamID || currentTeamId, @@ -127,7 +127,7 @@ const ButtonBinding = ({currentTeamId, binding, post, teamID, theme}: Props) => onPress={onPress} > diff --git a/app/components/post_list/post/body/content/embedded_bindings/embedded_binding.tsx b/app/components/post_list/post/body/content/embedded_bindings/embedded_binding.tsx index c8487328b..861d02cc5 100644 --- a/app/components/post_list/post/body/content/embedded_bindings/embedded_binding.tsx +++ b/app/components/post_list/post/body/content/embedded_bindings/embedded_binding.tsx @@ -51,7 +51,7 @@ const EmbeddedBinding = ({embed, location, post, theme}: Props) => { return ( <> - {Boolean(embed.label) && + {embed.label && { value={embed.label} /> } - {Boolean(embed.description) && + {embed.description && } diff --git a/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.tsx b/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.tsx index a9bba709f..a5b21bad2 100644 --- a/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.tsx +++ b/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.tsx @@ -61,8 +61,8 @@ const MenuBinding = ({binding, currentTeamId, post, teamID}: Props) => { finish(); }, [handleBindingSubmit, binding.bindings]); - const options = useMemo(() => binding.bindings?.map((b: AppBinding) => ({ - text: b.label, + const options = useMemo(() => binding.bindings?.map((b: AppBinding) => ({ + text: b.label || '', value: b.location || '', })), [binding.bindings]); diff --git a/app/components/post_list/post/body/content/message_attachments/action_menu/index.tsx b/app/components/post_list/post/body/content/message_attachments/action_menu/index.tsx index 1ebae42a1..3e3f765b8 100644 --- a/app/components/post_list/post/body/content/message_attachments/action_menu/index.tsx +++ b/app/components/post_list/post/body/content/message_attachments/action_menu/index.tsx @@ -1,11 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {selectAttachmentMenuAction} from '@actions/remote/integrations'; import AutocompleteSelector from '@components/autocomplete_selector'; import {useServerUrl} from '@context/server'; +import {filterOptions} from '@utils/message_attachment'; type Props = { dataSource?: string; @@ -18,12 +19,18 @@ type Props = { } const ActionMenu = ({dataSource, defaultOption, disabled, id, name, options, postId}: Props) => { - let isSelected: PostActionOption | undefined; const serverUrl = useServerUrl(); - if (defaultOption && options) { - isSelected = options.find((option) => option.value === defaultOption); - } - const [selected, setSelected] = useState(isSelected?.value); + + const filteredOptions = useMemo(() => { + return filterOptions(options); + }, [options]); + + const [selected, setSelected] = useState(() => { + if (defaultOption && options) { + return options.find((option) => option.value === defaultOption)?.value; + } + return undefined; + }); const handleSelect = useCallback(async (selectedItem: SelectedDialogOption) => { if (!selectedItem || Array.isArray(selectedItem)) { @@ -34,14 +41,14 @@ const ActionMenu = ({dataSource, defaultOption, disabled, id, name, options, pos if (result.data?.trigger_id) { setSelected(selectedItem.value); } - }, []); + }, [id, postId, serverUrl]); return ( } - {isValidUrl(attachment.thumb_url) && + {attachment.thumb_url && isValidUrl(attachment.thumb_url) && } {Boolean(attachment.text) && @@ -114,7 +114,7 @@ export default function MessageAttachment({attachment, channelId, layoutWidth, l theme={theme} /> } - {Boolean(attachment.fields?.length) && + {attachment.fields && attachment.fields?.length && } - {Boolean(attachment.footer) && + {attachment.footer && } - {Boolean(attachment.actions?.length) && + {attachment.actions && attachment.actions.length && } - {Boolean(metadata?.images?.[attachment.image_url]) && + {attachment.image_url && Boolean(metadata?.images?.[attachment.image_url]) && { type Props = { label: string; - options?: PostActionOption[]; + options?: DialogOption[]; onChange: (value: string) => void; helpText?: string; errorText?: string; diff --git a/app/hooks/apps.ts b/app/hooks/apps.ts index a9ebbd4da..070e6c5b7 100644 --- a/app/hooks/apps.ts +++ b/app/hooks/apps.ts @@ -31,7 +31,7 @@ export const useAppBinding = (context: UseAppBindingContext, config: UseAppBindi return useCallback(async (binding: AppBinding) => { const callContext = createCallContext( - binding.app_id, + binding.app_id!, binding.location, context.channel_id, context.team_id, diff --git a/app/screens/apps_form/apps_form_component.tsx b/app/screens/apps_form/apps_form_component.tsx index bf438c3c9..16137a6e1 100644 --- a/app/screens/apps_form/apps_form_component.tsx +++ b/app/screens/apps_form/apps_form_component.tsx @@ -103,6 +103,9 @@ function valuesReducer(state: AppFormValues, action: ValuesAction) { function initValues(fields?: AppField[]) { const values: AppFormValues = {}; fields?.forEach((e) => { + if (!e.name) { + return; + } if (e.type === 'bool') { values[e.name] = (e.value === true || String(e.value).toLowerCase() === 'true'); } else if (e.value) { @@ -327,7 +330,7 @@ function AppsFormComponent({ const performLookup = useCallback(async (name: string, userInput: string): Promise => { const field = form.fields?.find((f) => f.name === name); - if (!field) { + if (!field?.name) { return []; } @@ -409,6 +412,9 @@ function AppsFormComponent({ /> } {form.fields && form.fields.filter((f) => f.name !== form.submit_buttons).map((field) => { + if (!field.name) { + return null; + } const value = secureGetFromRecord(values, field.name); if (!value) { return null; diff --git a/app/screens/apps_form/apps_form_field.tsx b/app/screens/apps_form/apps_form_field.tsx index be10a6b1d..0675aa678 100644 --- a/app/screens/apps_form/apps_form_field.tsx +++ b/app/screens/apps_form/apps_form_field.tsx @@ -33,8 +33,8 @@ const dialogOptionToAppSelectOption = (option: DialogOption): AppSelectOption => }); const appSelectOptionToDialogOption = (option: AppSelectOption): DialogOption => ({ - text: option.label, - value: option.value, + text: option.label || '', + value: option.value || '', }); const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -100,6 +100,9 @@ function AppsFormField({ }, [onChange, field, name]); const getDynamicOptions = useCallback(async (userInput = ''): Promise => { + if (!field.name) { + return []; + } const options = await performLookup(field.name, userInput); return options.map(appSelectOptionToDialogOption); }, [performLookup, field]); @@ -126,7 +129,7 @@ function AppsFormField({ }, [field, value]); const selectedValue = useMemo(() => { - if (!value || !SelectableAppFieldTypes.includes(field.type)) { + if (!value || !SelectableAppFieldTypes.includes(field.type || '')) { return undefined; } @@ -135,7 +138,7 @@ function AppsFormField({ } if (Array.isArray(value)) { - return value.map((v) => v.value); + return value.map((v) => v.value || ''); } return value as string; diff --git a/app/screens/channel_info/app_bindings/index.tsx b/app/screens/channel_info/app_bindings/index.tsx index 4f65ac066..f64c5dcb9 100644 --- a/app/screens/channel_info/app_bindings/index.tsx +++ b/app/screens/channel_info/app_bindings/index.tsx @@ -49,7 +49,7 @@ const ChannelInfoAppBindings = ({channelId, teamId, dismissChannelInfo, serverUr const options = bindings.map((binding) => ( @@ -65,7 +65,7 @@ const BindingOptionItem = ({binding, onPress}: {binding: AppBinding; onPress: (b return ( (-1); const next = useRef(VALID_DATASOURCES.includes(dataSource)); + const filteredOptions = useMemo(() => { + return filterOptions(options) || []; + }, [options]); + // Callbacks const clearSearch = useCallback(() => { setTerm(''); @@ -275,8 +280,8 @@ function IntegrationSelector( }, [getChannels, dataSource]); const searchDynamicOptions = useCallback(async (searchTerm = '') => { - if (options && options !== integrationData && !searchTerm) { - setIntegrationData(options); + if (filteredOptions && filteredOptions !== integrationData && !searchTerm) { + setIntegrationData(filteredOptions); } if (!getDynamicOptions) { @@ -291,7 +296,7 @@ function IntegrationSelector( } else { setIntegrationData(searchData); } - }, [options, getDynamicOptions, integrationData]); + }, [filteredOptions, getDynamicOptions, integrationData]); const handleSelectProfile = useCallback((user: UserProfile): void => { if (!isMultiselect) { @@ -300,7 +305,7 @@ function IntegrationSelector( } setSelectedIds((current) => handleIdSelection(dataSource, current, user)); - }, [isMultiselect, handleIdSelection, handleSelect, close, dataSource]); + }, [isMultiselect, handleSelect, dataSource]); const onHandleMultiselectSubmit = useCallback(() => { if (dataSource === ViewConstants.DATA_SOURCE_USERS) { @@ -311,7 +316,7 @@ function IntegrationSelector( handleSelect(Object.values(multiselectSelected)); } close(); - }, [multiselectSelected, selectedIds, handleSelect]); + }, [dataSource, handleSelect, selectedIds, multiselectSelected]); const onSearch = useCallback((text: string) => { if (!text) { @@ -347,7 +352,7 @@ function IntegrationSelector( setLoading(false); }, General.SEARCH_TIMEOUT_MILLISECONDS); - }, [dataSource, integrationData, currentTeamId]); + }, [clearSearch, dataSource, integrationData, serverUrl, currentTeamId, searchDynamicOptions]); // Effects useNavButtonPressed(SUBMIT_BUTTON_ID, componentId, onHandleMultiselectSubmit, [onHandleMultiselectSubmit]); @@ -400,7 +405,7 @@ function IntegrationSelector( if (isMultiselect && Array.isArray(selected) && !([ViewConstants.DATA_SOURCE_USERS, ViewConstants.DATA_SOURCE_CHANNELS].includes(dataSource))) { for (const value of selected) { - const option = options?.find((opt) => opt.value === value); + const option = filteredOptions?.find((opt) => opt.value === value); if (option) { multiselectItems[value] = option; } @@ -440,7 +445,7 @@ function IntegrationSelector( /> ); - }, [style, dataSource, loading, intl]); + }, [style, dataSource, loading]); const renderNoResults = useCallback((): JSX.Element | null => { if (loading || page.current === -1) { @@ -518,7 +523,7 @@ function IntegrationSelector( ); - }, [multiselectSelected, selectedIds, style, theme]); + }, [dataSource, handleRemoveOption, multiselectSelected, selectedIds, style.separator, theme]); const userFetchFunction = useCallback(async (userFetchPage: number) => { const result = await fetchProfiles(serverUrl, userFetchPage, General.PROFILE_CHUNK_SIZE); diff --git a/app/screens/interactive_dialog/dialog_element.tsx b/app/screens/interactive_dialog/dialog_element.tsx index 3338b6089..ca8ee04e4 100644 --- a/app/screens/interactive_dialog/dialog_element.tsx +++ b/app/screens/interactive_dialog/dialog_element.tsx @@ -1,13 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback} from 'react'; +import React, {useCallback, useMemo} from 'react'; import AutocompleteSelector from '@components/autocomplete_selector'; import BoolSetting from '@components/settings/bool_setting'; import RadioSetting from '@components/settings/radio_setting'; import TextSetting from '@components/settings/text_setting'; import {selectKeyboardType as selectKB} from '@utils/integrations'; +import {filterOptions} from '@utils/message_attachment'; import type {KeyboardTypeOptions} from 'react-native'; @@ -86,6 +87,10 @@ function DialogElement({ onChange(name, newValue.value); }, [name, onChange]); + const filteredOptions = useMemo(() => { + return filterOptions(options); + }, [options]); + switch (type) { case 'text': case 'textarea': @@ -111,7 +116,7 @@ function DialogElement({ void}) => { - const handlePress = useCallback(preventDoubleTap(() => { + const handlePress = usePreventDoubleTap(useCallback(() => { onPress(binding); - }), [binding, onPress]); + }, [binding, onPress])); return ( { @@ -246,7 +256,7 @@ export function createCallRequest( }; } -export const makeCallErrorResponse = (errMessage: string): AppCallResponse => { +export const makeCallErrorResponse = (errMessage: string): AppCallResponse => { return { type: AppCallResponseTypes.ERROR, text: errMessage, @@ -312,7 +322,7 @@ function isAppCall(obj: unknown): obj is AppCall { const call = obj as AppCall; - if (typeof call.path !== 'string') { + if (call.path !== undefined && typeof call.path !== 'string') { return false; } @@ -348,7 +358,11 @@ function isAppSelectOption(v: unknown): v is AppSelectOption { const option = v as AppSelectOption; - if (typeof option.label !== 'string' || typeof option.value !== 'string') { + if (option.label !== undefined && typeof option.label !== 'string') { + return false; + } + + if (option.value !== undefined && typeof option.value !== 'string') { return false; } @@ -366,7 +380,11 @@ function isAppField(v: unknown): v is AppField { const field = v as AppField; - if (typeof field.name !== 'string' || typeof field.type !== 'string') { + if (field.name !== undefined && typeof field.name !== 'string') { + return false; + } + + if (field.type !== undefined && typeof field.type !== 'string') { return false; } @@ -494,7 +512,11 @@ export function isAppBinding(obj: unknown): obj is AppBinding { const binding = obj as AppBinding; - if (typeof binding.app_id !== 'string' || typeof binding.label !== 'string') { + if (binding.app_id !== undefined && typeof binding.app_id !== 'string') { + return false; + } + + if (binding.label !== undefined && typeof binding.label !== 'string') { return false; } diff --git a/app/utils/message_attachment.test.ts b/app/utils/message_attachment.test.ts index bd3f2281b..9073db6e5 100644 --- a/app/utils/message_attachment.test.ts +++ b/app/utils/message_attachment.test.ts @@ -92,13 +92,13 @@ describe('isPostAction', () => { expect(isPostAction(nonObjectInput)).toBe(false); }); - test('returns false for missing id', () => { - const invalidPostAction = {name: 'name'}; + test('returns false for wrong id type', () => { + const invalidPostAction = {id: {}, name: 'name'}; expect(isPostAction(invalidPostAction)).toBe(false); }); - test('returns false for missing name', () => { - const invalidPostAction = {id: 'id'}; + test('returns false for wrong name type', () => { + const invalidPostAction = {id: 'id', name: {}}; expect(isPostAction(invalidPostAction)).toBe(false); }); @@ -154,11 +154,6 @@ describe('isMessageAttachmentField', () => { expect(isMessageAttachmentField(nonObjectInput)).toBe(false); }); - test('returns false for missing title', () => { - const invalidField = {value: 'value', short: true}; - expect(isMessageAttachmentField(invalidField)).toBe(false); - }); - test('returns false for non-string title', () => { const invalidField = {title: 123, value: 'value', short: true}; expect(isMessageAttachmentField(invalidField)).toBe(false); @@ -169,8 +164,8 @@ describe('isMessageAttachmentField', () => { expect(isMessageAttachmentField(invalidField)).toBe(false); }); - test('returns false for missing value', () => { - const invalidField = {title: 'title', short: true}; + test('returns false for invalid value', () => { + const invalidField = {title: 'title', value: {toString: 123}, short: true}; expect(isMessageAttachmentField(invalidField)).toBe(false); }); diff --git a/app/utils/message_attachment.ts b/app/utils/message_attachment.ts index 4232c2b97..08d8da03a 100644 --- a/app/utils/message_attachment.ts +++ b/app/utils/message_attachment.ts @@ -39,19 +39,11 @@ function isPostAction(v: unknown): v is PostAction { return false; } - if (!('id' in v)) { + if ('id' in v && typeof v.id !== 'string') { return false; } - if (typeof v.id !== 'string') { - return false; - } - - if (!('name' in v)) { - return false; - } - - if (typeof v.name !== 'string') { + if ('name' in v && typeof v.name !== 'string') { return false; } @@ -86,7 +78,7 @@ function isPostAction(v: unknown): v is PostAction { return true; } -function isMessageAttachmentField(v: unknown) { +function isMessageAttachmentField(v: unknown): v is MessageAttachmentField { if (typeof v !== 'object') { return false; } @@ -95,19 +87,11 @@ function isMessageAttachmentField(v: unknown) { return false; } - if (!('title' in v)) { + if ('title' in v && typeof v.title !== 'string') { return false; } - if (typeof v.title !== 'string') { - return false; - } - - if (!('value' in v)) { - return false; - } - - if (typeof v.value === 'object' && v.value && 'toString' in v.value && typeof v.value.toString !== 'function') { + if ('value' in v && typeof v.value === 'object' && v.value && 'toString' in v.value && typeof v.value.toString !== 'function') { return false; } @@ -194,6 +178,24 @@ function isMessageAttachment(v: unknown): v is MessageAttachment { return true; } +export function filterOptions(options: PostActionOption[] | undefined) { + return options?.reduce((acc: DialogOption[], option) => { + let optionText = option.text; + let optionValue = option.value; + + if (optionText && !optionValue) { + optionValue = optionText; + } else if (optionValue && !optionText) { + optionText = optionValue; + } + + if (optionText && optionValue) { + acc.push({text: optionText, value: optionValue}); + } + return acc; + }, []); +} + export const testExports = { isMessageAttachment, isMessageAttachmentField, diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 160b1fed0..d1a8bc8f4 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -29,6 +29,7 @@ "api.channel.add_guest.added": "{addedUsername} added to the channel as a guest by {username}.", "api.channel.add_member.added": "{addedUsername} added to the channel by {username}.", "api.channel.guest_join_channel.post_and_forget": "{username} joined the channel as a guest.", + "apps.binding.default_button_name": "Submit", "apps.error": "Error: {error}", "apps.error.command.field_missing": "Required fields missing: `{fieldName}`.", "apps.error.command.same_channel": "Channel repeated for field `{fieldName}`: `{option}`.", @@ -50,6 +51,7 @@ "apps.error.parser.empty_value": "Empty values are not allowed.", "apps.error.parser.execute_non_leaf": "You must select a subcommand.", "apps.error.parser.missing_binding": "Missing command bindings.", + "apps.error.parser.missing_field_name": "Field name is missing.", "apps.error.parser.missing_field_value": "Field value is missing.", "apps.error.parser.missing_list_end": "Expected list closing token.", "apps.error.parser.missing_quote": "Matching double quote expected before end of input.", diff --git a/types/api/apps.d.ts b/types/api/apps.d.ts index f68fa8544..b7d0beeea 100644 --- a/types/api/apps.d.ts +++ b/types/api/apps.d.ts @@ -23,15 +23,15 @@ type AppsState = { }; type AppBinding = { - app_id: string; - location: string; + app_id?: string; + location?: string; icon?: string; // Label is the (usually short) primary text to display at the location. // - For LocationPostMenu is the menu item text. // - For LocationChannelHeader is the dropdown text. // - For LocationCommand is the name of the command - label: string; + label?: string; // Hint is the secondary text to display // - LocationPostMenu: not used @@ -60,7 +60,7 @@ type AppCallValues = { }; type AppCall = { - path: string; + path?: string; expand?: AppExpand; state?: any; }; @@ -152,8 +152,8 @@ type AppFormValue = string | boolean | number | AppSelectOption | AppSelectOptio type AppFormValues = {[name: string]: AppFormValue}; type AppSelectOption = { - label: string; - value: string; + label?: string; + value?: string; icon_data?: string; }; @@ -163,8 +163,8 @@ type AppFieldType = string; type AppField = { // Name is the name of the JSON field to use. - name: string; - type: AppFieldType; + name?: string; + type?: AppFieldType; is_required?: boolean; readonly?: boolean; diff --git a/types/api/integrations.d.ts b/types/api/integrations.d.ts index a51ba3182..a42688100 100644 --- a/types/api/integrations.d.ts +++ b/types/api/integrations.d.ts @@ -105,8 +105,8 @@ type PostAction = { }; type PostActionOption = { - text: string; - value: string; + text?: string; + value?: string; }; type PostActionIntegration = { diff --git a/types/api/posts.d.ts b/types/api/posts.d.ts index 01d350e06..52b83592d 100644 --- a/types/api/posts.d.ts +++ b/types/api/posts.d.ts @@ -107,29 +107,29 @@ type ProcessedPosts = { } type MessageAttachment = { - id: number; - fallback: string; - color: string; - pretext: string; - author_name: string; - author_link: string; - author_icon: string; - title: string; - title_link: string; - text: string; - fields: MessageAttachmentField[]; - image_url: string; - thumb_url: string; - footer: string; - footer_icon: string; - timestamp: number | string; + id?: number; + fallback?: string; + color?: string; + pretext?: string; + author_name?: string; + author_link?: string; + author_icon?: string; + title?: string; + title_link?: string; + text?: string; + fields?: MessageAttachmentField[]; + image_url?: string; + thumb_url?: string; + footer?: string; + footer_icon?: string; + timestamp?: number | string; actions?: PostAction[]; }; type MessageAttachmentField = { - title: string; - value: any; - short: boolean; + title?: string; + value?: any; + short?: boolean; } type PostSearchParams = {