Make attachments and app bindings more robust yet secure (#8570)
* Make attachments and app bindings more robust yet secure * Fix tests * i18n extract * Address feedback --------- Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
parent
78997d059d
commit
782b1c69a8
22 changed files with 265 additions and 173 deletions
|
|
@ -31,7 +31,7 @@ export async function handleBindingClick<Res=unknown>(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<Res>(errMsg)};
|
||||
}
|
||||
|
||||
const res: AppCallResponse<Res> = {
|
||||
|
|
@ -48,7 +48,7 @@ export async function handleBindingClick<Res=unknown>(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<Res>(errMsg)};
|
||||
}
|
||||
|
||||
const callRequest = createCallRequest(
|
||||
|
|
@ -83,7 +83,7 @@ export async function doAppSubmit<Res=unknown>(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<Res>(errMsg)};
|
||||
}
|
||||
|
||||
cleanForm(res.form);
|
||||
|
|
@ -96,7 +96,7 @@ export async function doAppSubmit<Res=unknown>(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<Res>(errMsg)};
|
||||
}
|
||||
|
||||
return {data: res};
|
||||
|
|
@ -107,7 +107,7 @@ export async function doAppSubmit<Res=unknown>(serverUrl: string, inCall: AppCal
|
|||
}, {
|
||||
type: responseType,
|
||||
});
|
||||
return {error: makeCallErrorResponse(errMsg)};
|
||||
return {error: makeCallErrorResponse<Res>(errMsg)};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -116,7 +116,7 @@ export async function doAppSubmit<Res=unknown>(serverUrl: string, inCall: AppCal
|
|||
defaultMessage: 'Received an unexpected error.',
|
||||
});
|
||||
logDebug('error on doAppSubmit', getFullErrorMessage(error));
|
||||
return {error: makeCallErrorResponse(errMsg)};
|
||||
return {error: makeCallErrorResponse<Res>(errMsg)};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -135,7 +135,7 @@ export async function doAppFetchForm<Res=unknown>(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<Res>(errMsg)};
|
||||
}
|
||||
cleanForm(res.form);
|
||||
return {data: res};
|
||||
|
|
@ -144,7 +144,7 @@ export async function doAppFetchForm<Res=unknown>(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<Res>(errMsg)};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -153,7 +153,7 @@ export async function doAppFetchForm<Res=unknown>(serverUrl: string, call: AppCa
|
|||
defaultMessage: 'Received an unexpected error.',
|
||||
});
|
||||
logDebug('error on doAppFetchForm', getFullErrorMessage(error));
|
||||
return {error: makeCallErrorResponse(errMsg)};
|
||||
return {error: makeCallErrorResponse<Res>(errMsg)};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -175,7 +175,7 @@ export async function doAppLookup<Res=unknown>(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<Res>(errMsg)};
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
|
|
@ -184,7 +184,7 @@ export async function doAppLookup<Res=unknown>(serverUrl: string, call: AppCallR
|
|||
defaultMessage: 'Received an unexpected error.',
|
||||
});
|
||||
logDebug('error on doAppLookup', getFullErrorMessage(error));
|
||||
return {error: makeCallErrorResponse(errMsg)};
|
||||
return {error: makeCallErrorResponse<Res>(errMsg)};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AppContext> => {
|
||||
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 || '',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
<ButtonBindingText
|
||||
message={binding.label}
|
||||
message={binding.label || intl.formatMessage({id: 'apps.binding.default_button_name', defaultMessage: 'Submit'})}
|
||||
style={style.text}
|
||||
/>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const EmbeddedBinding = ({embed, location, post, theme}: Props) => {
|
|||
return (
|
||||
<>
|
||||
<View style={style.container}>
|
||||
{Boolean(embed.label) &&
|
||||
{embed.label &&
|
||||
<EmbedTitle
|
||||
channelId={post.channelId}
|
||||
location={location}
|
||||
|
|
@ -59,11 +59,11 @@ const EmbeddedBinding = ({embed, location, post, theme}: Props) => {
|
|||
value={embed.label}
|
||||
/>
|
||||
}
|
||||
{Boolean(embed.description) &&
|
||||
{embed.description &&
|
||||
<EmbedText
|
||||
channelId={post.channelId}
|
||||
location={location}
|
||||
value={embed.description!}
|
||||
value={embed.description}
|
||||
theme={theme}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ const MenuBinding = ({binding, currentTeamId, post, teamID}: Props) => {
|
|||
finish();
|
||||
}, [handleBindingSubmit, binding.bindings]);
|
||||
|
||||
const options = useMemo(() => binding.bindings?.map<PostActionOption>((b: AppBinding) => ({
|
||||
text: b.label,
|
||||
const options = useMemo(() => binding.bindings?.map<DialogOption>((b: AppBinding) => ({
|
||||
text: b.label || '',
|
||||
value: b.location || '',
|
||||
})), [binding.bindings]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<AutocompleteSelector
|
||||
placeholder={name}
|
||||
dataSource={dataSource}
|
||||
isMultiselect={false}
|
||||
options={options}
|
||||
options={filteredOptions}
|
||||
selected={selected}
|
||||
onSelected={handleSelect}
|
||||
disabled={disabled}
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export default function MessageAttachment({attachment, channelId, layoutWidth, l
|
|||
value={attachment.title}
|
||||
/>
|
||||
}
|
||||
{isValidUrl(attachment.thumb_url) &&
|
||||
{attachment.thumb_url && isValidUrl(attachment.thumb_url) &&
|
||||
<AttachmentThumbnail uri={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 &&
|
||||
<AttachmentFields
|
||||
baseTextStyle={style.message}
|
||||
blockStyles={blockStyles}
|
||||
|
|
@ -126,21 +126,21 @@ export default function MessageAttachment({attachment, channelId, layoutWidth, l
|
|||
theme={theme}
|
||||
/>
|
||||
}
|
||||
{Boolean(attachment.footer) &&
|
||||
{attachment.footer &&
|
||||
<AttachmentFooter
|
||||
icon={attachment.footer_icon}
|
||||
text={attachment.footer}
|
||||
theme={theme}
|
||||
/>
|
||||
}
|
||||
{Boolean(attachment.actions?.length) &&
|
||||
{attachment.actions && attachment.actions.length &&
|
||||
<AttachmentActions
|
||||
actions={attachment.actions!}
|
||||
postId={postId}
|
||||
theme={theme}
|
||||
/>
|
||||
}
|
||||
{Boolean(metadata?.images?.[attachment.image_url]) &&
|
||||
{attachment.image_url && Boolean(metadata?.images?.[attachment.image_url]) &&
|
||||
<AttachmentImage
|
||||
imageUrl={attachment.image_url}
|
||||
imageMetadata={metadata!.images![attachment.image_url]!}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
|||
|
||||
type Props = {
|
||||
label: string;
|
||||
options?: PostActionOption[];
|
||||
options?: DialogOption[];
|
||||
onChange: (value: string) => void;
|
||||
helpText?: string;
|
||||
errorText?: string;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -104,6 +104,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) {
|
||||
|
|
@ -328,7 +331,7 @@ function AppsFormComponent({
|
|||
|
||||
const performLookup = useCallback(async (name: string, userInput: string): Promise<AppSelectOption[]> => {
|
||||
const field = form.fields?.find((f) => f.name === name);
|
||||
if (!field) {
|
||||
if (!field?.name) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -411,6 +414,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;
|
||||
|
|
|
|||
|
|
@ -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<DialogOption[]> => {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ const ChannelInfoAppBindings = ({channelId, teamId, dismissChannelInfo, serverUr
|
|||
|
||||
const options = bindings.map((binding) => (
|
||||
<BindingOptionItem
|
||||
key={binding.app_id + binding.location}
|
||||
key={(binding.app_id || '') + (binding.location || '')}
|
||||
binding={binding}
|
||||
onPress={onPress}
|
||||
/>
|
||||
|
|
@ -65,7 +65,7 @@ const BindingOptionItem = ({binding, onPress}: {binding: AppBinding; onPress: (b
|
|||
|
||||
return (
|
||||
<OptionItem
|
||||
label={binding.label}
|
||||
label={binding.label || ''}
|
||||
icon={binding.icon}
|
||||
action={handlePress}
|
||||
type='default'
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
popTopScreen, setButtons,
|
||||
} from '@screens/navigation';
|
||||
import {filterChannelsMatchingTerm} from '@utils/channel';
|
||||
import {filterOptions} from '@utils/message_attachment';
|
||||
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {secureGetFromRecord} from '@utils/types';
|
||||
import {typography} from '@utils/typography';
|
||||
|
|
@ -190,6 +191,10 @@ function IntegrationSelector(
|
|||
const page = useRef<number>(-1);
|
||||
const next = useRef<boolean>(VALID_DATASOURCES.includes(dataSource));
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
return filterOptions(options) || [];
|
||||
}, [options]);
|
||||
|
||||
// Callbacks
|
||||
const clearSearch = useCallback(() => {
|
||||
setTerm('');
|
||||
|
|
@ -276,8 +281,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) {
|
||||
|
|
@ -292,7 +297,7 @@ function IntegrationSelector(
|
|||
} else {
|
||||
setIntegrationData(searchData);
|
||||
}
|
||||
}, [options, getDynamicOptions, integrationData]);
|
||||
}, [filteredOptions, getDynamicOptions, integrationData]);
|
||||
|
||||
const handleSelectProfile = useCallback((user: UserProfile): void => {
|
||||
if (!isMultiselect) {
|
||||
|
|
@ -301,7 +306,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) {
|
||||
|
|
@ -312,7 +317,7 @@ function IntegrationSelector(
|
|||
handleSelect(Object.values(multiselectSelected));
|
||||
}
|
||||
close();
|
||||
}, [multiselectSelected, selectedIds, handleSelect]);
|
||||
}, [dataSource, handleSelect, selectedIds, multiselectSelected]);
|
||||
|
||||
const onSearch = useCallback((text: string) => {
|
||||
if (!text) {
|
||||
|
|
@ -348,7 +353,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]);
|
||||
|
|
@ -401,7 +406,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;
|
||||
}
|
||||
|
|
@ -441,7 +446,7 @@ function IntegrationSelector(
|
|||
/>
|
||||
</View>
|
||||
);
|
||||
}, [style, dataSource, loading, intl]);
|
||||
}, [style, dataSource, loading]);
|
||||
|
||||
const renderNoResults = useCallback((): JSX.Element | null => {
|
||||
if (loading || page.current === -1) {
|
||||
|
|
@ -519,7 +524,7 @@ function IntegrationSelector(
|
|||
<View style={style.separator}/>
|
||||
</>
|
||||
);
|
||||
}, [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);
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<AutocompleteSelector
|
||||
label={displayName}
|
||||
dataSource={dataSource}
|
||||
options={options}
|
||||
options={filteredOptions}
|
||||
optional={optional}
|
||||
onSelected={handleSelect}
|
||||
helpText={helpText}
|
||||
|
|
@ -129,7 +134,7 @@ function DialogElement({
|
|||
label={displayName}
|
||||
helpText={helpText}
|
||||
errorText={errorText}
|
||||
options={options}
|
||||
options={filteredOptions}
|
||||
onChange={handleChange}
|
||||
testID={testID}
|
||||
value={getStringValue(value)}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import {switchMap, distinctUntilChanged} from 'rxjs/operators';
|
|||
import {postEphemeralCallResponseForPost} from '@actions/remote/apps';
|
||||
import OptionItem from '@components/option_item';
|
||||
import {useAppBinding} from '@hooks/apps';
|
||||
import {usePreventDoubleTap} from '@hooks/utils';
|
||||
import {observeChannel} from '@queries/servers/channel';
|
||||
import {observeCurrentTeamId} from '@queries/servers/system';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
import {isSystemMessage} from '@utils/post';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
|
@ -70,13 +70,13 @@ const AppBindingsPostOptions = ({bottomSheetId, serverUrl, post, teamId, binding
|
|||
};
|
||||
|
||||
const BindingOptionItem = ({binding, onPress}: {binding: AppBinding; onPress: (binding: AppBinding) => void}) => {
|
||||
const handlePress = useCallback(preventDoubleTap(() => {
|
||||
const handlePress = usePreventDoubleTap(useCallback(() => {
|
||||
onPress(binding);
|
||||
}), [binding, onPress]);
|
||||
}, [binding, onPress]));
|
||||
|
||||
return (
|
||||
<OptionItem
|
||||
label={binding.label}
|
||||
label={binding.label || ''}
|
||||
icon={binding.icon}
|
||||
action={handlePress}
|
||||
type='default'
|
||||
|
|
|
|||
|
|
@ -190,18 +190,28 @@ function cleanStaticSelect(field: AppField): void {
|
|||
return;
|
||||
}
|
||||
|
||||
let value = option.value;
|
||||
if (!value) {
|
||||
value = option.label;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
toRemove.unshift(i);
|
||||
return;
|
||||
}
|
||||
|
||||
if (usedLabels[label]) {
|
||||
toRemove.unshift(i);
|
||||
return;
|
||||
}
|
||||
|
||||
if (usedValues[option.value]) {
|
||||
if (usedValues[value]) {
|
||||
toRemove.unshift(i);
|
||||
return;
|
||||
}
|
||||
|
||||
usedLabels[label] = true;
|
||||
usedValues[option.value] = true;
|
||||
usedValues[value] = true;
|
||||
});
|
||||
|
||||
toRemove.forEach((i) => {
|
||||
|
|
@ -246,7 +256,7 @@ export function createCallRequest(
|
|||
};
|
||||
}
|
||||
|
||||
export const makeCallErrorResponse = (errMessage: string): AppCallResponse<any> => {
|
||||
export const makeCallErrorResponse = <T=unknown>(errMessage: string): AppCallResponse<T> => {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
16
types/api/apps.d.ts
vendored
16
types/api/apps.d.ts
vendored
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
4
types/api/integrations.d.ts
vendored
4
types/api/integrations.d.ts
vendored
|
|
@ -105,8 +105,8 @@ type PostAction = {
|
|||
};
|
||||
|
||||
type PostActionOption = {
|
||||
text: string;
|
||||
value: string;
|
||||
text?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
type PostActionIntegration = {
|
||||
|
|
|
|||
38
types/api/posts.d.ts
vendored
38
types/api/posts.d.ts
vendored
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue