Sync binding filtering with webapp (#5655)

* Sync binding filtering with webapp

* Add return types and use variable instead of updating actions

* Allow bindings with no call but form with calls
This commit is contained in:
Daniel Espino García 2021-09-21 09:08:35 +02:00 committed by GitHub
parent 2380c520d2
commit c753be5bfe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 1252 additions and 311 deletions

View file

@ -8,7 +8,7 @@ import {ActionFunc, DispatchFunc} from '@mm-redux/types/actions';
import {AppCallResponse, AppCallRequest, AppCallType, AppContext} from '@mm-redux/types/apps';
import {CommandArgs} from '@mm-redux/types/integrations';
import {Post} from '@mm-redux/types/posts';
import {makeCallErrorResponse} from '@utils/apps';
import {cleanForm, makeCallErrorResponse} from '@utils/apps';
export function doAppCall<Res=unknown>(call: AppCallRequest, type: AppCallType, intl: any): ActionFunc {
return async () => {
@ -30,6 +30,8 @@ export function doAppCall<Res=unknown>(call: AppCallRequest, type: AppCallType,
return {error: makeCallErrorResponse(errMsg)};
}
cleanForm(res.form);
return {data: res};
}
case AppCallResponseTypes.NAVIGATE:

View file

@ -42,6 +42,7 @@ import {
getCurrentTeam,
selectChannelByName,
errorMessage as parserErrorMessage,
filterEmptyOptions,
autocompleteUsersInChannel,
autocompleteChannels,
ExtendedAutocompleteSuggestion,
@ -1287,7 +1288,8 @@ export class AppCommandParser {
}));
}
const items = callResponse?.data?.items;
let items = callResponse?.data?.items;
items = items?.filter(filterEmptyOptions);
if (!items?.length) {
return [{
Complete: '',
@ -1313,7 +1315,7 @@ export class AppCommandParser {
}
return ({
Complete: complete,
Description: s.label,
Description: s.label || s.value,
Suggestion: s.value,
Hint: '',
IconData: s.icon_data || parsed.binding?.icon || '',

View file

@ -86,7 +86,10 @@ export {
export {getChannelByNameAndTeamName, getChannel, autocompleteChannels} from '@mm-redux/actions/channels';
export {doAppCall} from '@actions/apps';
export {createCallRequest} from '@utils/apps';
export {
createCallRequest,
filterEmptyOptions,
} from '@utils/apps';
export const getStore = () => Store.redux;

View file

@ -4,7 +4,8 @@
import React from 'react';
import {View} from 'react-native';
import {copyAndFillBindings} from '@utils/apps';
import {AppBindingLocations} from '@mm-redux/constants/apps';
import {cleanBinding} from '@utils/apps';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import EmbedText from './embed_text';
@ -40,7 +41,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme:Theme) => {
const EmbeddedBinding = ({embed, postId, theme}: Props) => {
const style = getStyleSheet(theme);
const bindings = copyAndFillBindings(embed)?.bindings;
const bindings = cleanBinding(embed, AppBindingLocations.IN_POST)?.bindings;
return (
<>

View file

@ -7,13 +7,11 @@ Array [
"bindings": Array [
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"label": "a",
"location": "/post_menu/locA",
},
],
"call": Object {},
"location": "/post_menu",
},
Object {
@ -21,13 +19,11 @@ Array [
"bindings": Array [
Object {
"app_id": "2",
"bindings": undefined,
"call": Object {},
"label": "a",
"location": "/post_menu/locA",
},
],
"call": Object {},
"location": "/post_menu",
},
Object {
@ -35,7 +31,6 @@ Array [
"bindings": Array [
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"icon": "icon",
"label": "b",
@ -43,13 +38,11 @@ Array [
},
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"label": "c",
"location": "/channel_header/locC",
},
],
"call": Object {},
"location": "/channel_header",
},
Object {
@ -57,14 +50,19 @@ Array [
"bindings": Array [
Object {
"app_id": "2",
"bindings": undefined,
"call": Object {},
"icon": "icon",
"label": "locB",
"location": "/channel_header/locB",
},
Object {
"app_id": "2",
"call": Object {},
"icon": "icon",
"label": "c",
"location": "/channel_header/locC",
},
],
"call": Object {},
"location": "/channel_header",
},
Object {
@ -72,13 +70,17 @@ Array [
"bindings": Array [
Object {
"app_id": "3",
"bindings": undefined,
"call": Object {},
"label": "locB",
"location": "/channel_header/locB",
},
Object {
"app_id": "3",
"call": Object {},
"label": "c",
"location": "/channel_header/locC",
},
],
"call": Object {},
"location": "/channel_header",
},
Object {
@ -86,13 +88,11 @@ Array [
"bindings": Array [
Object {
"app_id": "3",
"bindings": undefined,
"call": Object {},
"label": "c",
"location": "/command/locC",
},
],
"call": Object {},
"location": "/command",
},
]
@ -105,20 +105,17 @@ Array [
"bindings": Array [
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"label": "a",
"location": "/post_menu/locA",
},
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"label": "a",
"location": "/post_menu/locB",
},
],
"call": Object {},
"location": "/post_menu",
},
Object {
@ -126,14 +123,12 @@ Array [
"bindings": Array [
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"icon": "icon",
"label": "b",
"location": "/channel_header/locB",
},
],
"call": Object {},
"location": "/channel_header",
},
Object {
@ -144,23 +139,46 @@ Array [
"bindings": Array [
Object {
"app_id": "3",
"bindings": undefined,
"call": Object {},
"label": "subC1",
"location": "/command/locC/subC1",
},
Object {
"app_id": "3",
"call": Object {},
"label": "c2",
"location": "/command/locC/subC2",
},
],
"call": Object {},
"label": "c",
"location": "/command/locC",
},
Object {
"app_id": "3",
"bindings": Array [
Object {
"app_id": "3",
"call": Object {},
"label": "subC1",
"location": "/command/locD/subC1",
},
],
"label": "d",
"location": "/command/locD",
},
],
"call": Object {},
"location": "/command",
},
Object {
"app_id": "1",
"bindings": Array [],
"bindings": Array [
Object {
"app_id": "1",
"call": Object {},
"label": "locC",
"location": "/command/locC",
},
],
"location": "/command",
},
Object {
@ -171,14 +189,12 @@ Array [
"bindings": Array [
Object {
"app_id": "2",
"bindings": undefined,
"call": Object {},
"label": "c1",
"location": "/command/locC/subC1",
},
Object {
"app_id": "2",
"bindings": undefined,
"call": Object {},
"label": "c2",
"location": "/command/locC/subC2",
@ -189,7 +205,6 @@ Array [
"location": "/command/locC",
},
],
"call": Object {},
"location": "/command",
},
]
@ -202,13 +217,17 @@ Array [
"bindings": Array [
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"label": "locA",
"location": "/post_menu/locA",
},
Object {
"app_id": "1",
"call": Object {},
"label": "a",
"location": "/post_menu/locB",
},
],
"call": Object {},
"location": "/post_menu",
},
Object {
@ -216,25 +235,29 @@ Array [
"bindings": Array [
Object {
"app_id": "2",
"bindings": undefined,
"call": Object {},
"label": "a",
"location": "/post_menu/locA",
},
Object {
"app_id": "2",
"bindings": undefined,
"call": Object {},
"label": "b",
"location": "/post_menu/locB",
},
],
"call": Object {},
"location": "/post_menu",
},
Object {
"app_id": "3",
"bindings": Array [],
"bindings": Array [
Object {
"app_id": "3",
"call": Object {},
"label": "locA",
"location": "/post_menu/locA",
},
],
"location": "/post_menu",
},
Object {
@ -242,14 +265,12 @@ Array [
"bindings": Array [
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"icon": "icon",
"label": "b",
"location": "/channel_header/locB",
},
],
"call": Object {},
"location": "/channel_header",
},
Object {
@ -257,13 +278,11 @@ Array [
"bindings": Array [
Object {
"app_id": "3",
"bindings": undefined,
"call": Object {},
"label": "c",
"location": "/command/locC",
},
],
"call": Object {},
"location": "/command",
},
]
@ -276,13 +295,11 @@ Array [
"bindings": Array [
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"label": "a",
"location": "/post_menu/locA",
},
],
"call": Object {},
"location": "/post_menu",
},
Object {
@ -290,13 +307,11 @@ Array [
"bindings": Array [
Object {
"app_id": "2",
"bindings": undefined,
"call": Object {},
"label": "a",
"location": "/post_menu/locA",
},
],
"call": Object {},
"location": "/post_menu",
},
Object {
@ -304,14 +319,12 @@ Array [
"bindings": Array [
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"icon": "icon",
"label": "b",
"location": "/channel_header/locB",
},
],
"call": Object {},
"location": "/channel_header",
},
Object {
@ -319,13 +332,11 @@ Array [
"bindings": Array [
Object {
"app_id": "3",
"bindings": undefined,
"call": Object {},
"label": "c",
"location": "/command/locC",
},
],
"call": Object {},
"location": "/command",
},
]

View file

@ -11,8 +11,11 @@ import {validateBindings} from '@utils/apps';
export function bindings(state: AppBinding[] = [], action: GenericAction): AppBinding[] {
switch (action.type) {
case AppsTypes.RECEIVED_APP_BINDINGS: {
validateBindings(action.data);
return action.data || [];
const newBindings = validateBindings(action.data);
if (!newBindings.length && !state.length) {
return state;
}
return newBindings;
}
default:
return state;

View file

@ -18,6 +18,7 @@ import {AppCallRequest, AppField, AppForm, AppFormValue, AppFormValues, AppLooku
import {DialogElement} from '@mm-redux/types/integrations';
import {Theme} from '@mm-redux/types/theme';
import {checkDialogElementForError, checkIfErrorsMatchElements} from '@mm-redux/utils/integration_utils';
import {filterEmptyOptions} from '@utils/apps';
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -245,8 +246,11 @@ export default class AppsFormComponent extends PureComponent<Props, State> {
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
return callResp.data?.items || [];
case AppCallResponseTypes.OK: {
let items = callResp.data?.items || [];
items = items.filter(filterEmptyOptions);
return items;
}
case AppCallResponseTypes.FORM:
case AppCallResponseTypes.NAVIGATE: {
const errMsg = intl.formatMessage({

File diff suppressed because it is too large Load diff

View file

@ -1,135 +1,203 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppBindingLocations, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {AppBindingLocations, AppCallResponseTypes, AppFieldTypes} from '@mm-redux/constants/apps';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {AppBinding, AppCall, AppCallRequest, AppCallValues, AppContext, AppExpand} from '@mm-redux/types/apps';
import {AppBinding, AppCall, AppCallRequest, AppCallValues, AppContext, AppExpand, AppField, AppForm, AppSelectOption} from '@mm-redux/types/apps';
import {Config} from '@mm-redux/types/config';
import {GlobalState} from '@mm-redux/types/store';
export function appsEnabled(state: GlobalState) { // eslint-disable-line @typescript-eslint/no-unused-vars
export function appsEnabled(state: GlobalState): boolean { // eslint-disable-line @typescript-eslint/no-unused-vars
const enabled = getConfig(state)?.['FeatureFlagAppsEnabled' as keyof Partial<Config>];
return enabled === 'true';
}
export function copyAndFillBindings(binding?: AppBinding): AppBinding | undefined {
if (!binding) {
return undefined;
}
const copy = JSON.parse(JSON.stringify(binding));
fillAndTrimBindingsInformation(copy);
return copy;
export function cleanBinding(binding: AppBinding, topLocation: string): AppBinding {
return cleanBindingRec(binding, topLocation, 0);
}
// fillAndTrimBindingsInformation does:
// - Build the location (e.g. channel_header/binding)
// - Inherit app calls
// - Inherit app ids
// - Trim invalid bindings (do not have an app call or app id at the leaf)
export function fillAndTrimBindingsInformation(binding?: AppBinding) {
function cleanBindingRec(binding: AppBinding, topLocation: string, depth: number): AppBinding {
if (!binding) {
return;
return binding;
}
binding.bindings?.forEach((b) => {
// Propagate id down if not defined
const toRemove: number[] = [];
const usedLabels: {[label: string]: boolean} = {};
binding.bindings?.forEach((b, i) => {
// Inheritance and defaults
if (!b.call && binding.call) {
b.call = binding.call;
}
if (b.form) {
cleanForm(b.form);
} else if (binding.form) {
b.form = binding.form;
}
if (!b.app_id) {
b.app_id = binding.app_id;
}
// Compose location
b.location = binding.location + '/' + b.location;
// Propagate call down if not defined
if (!b.call) {
b.call = binding.call;
if (!b.label) {
b.label = b.location || '';
}
fillAndTrimBindingsInformation(b);
b.location = binding.location + '/' + b.location;
// Validation
if (!b.label) {
toRemove.unshift(i);
return;
}
switch (topLocation) {
case AppBindingLocations.COMMAND: {
if (b.label.match(/ |\t/)) {
toRemove.unshift(i);
return;
}
if (usedLabels[b.label]) {
toRemove.unshift(i);
return;
}
break;
}
case AppBindingLocations.IN_POST: {
if (usedLabels[b.label]) {
toRemove.unshift(i);
return;
}
break;
}
}
if (b.bindings?.length) {
cleanBindingRec(b, topLocation, depth + 1);
// Remove invalid branches
if (!b.bindings?.length) {
toRemove.unshift(i);
return;
}
} else {
// Remove leaves without a call
if (!b.call && !b.form?.call) {
toRemove.unshift(i);
return;
}
// Remove leaves without app id
if (!b.app_id) {
toRemove.unshift(i);
return;
}
}
usedLabels[b.label] = true;
});
// Trim branches without app_id
if (!binding.app_id) {
binding.bindings = binding.bindings?.filter((v) => v.app_id);
}
toRemove.forEach((i) => {
binding.bindings?.splice(i, 1);
});
// Trim branches without calls
if (!binding.call) {
binding.bindings = binding.bindings?.filter((v) => v.call);
}
// Pull up app_id if needed
if (binding.bindings?.length && !binding.app_id) {
binding.app_id = binding.bindings[0].app_id;
}
// Pull up call if needed
if (binding.bindings?.length && !binding.call) {
binding.call = binding.bindings[0].call;
}
return binding;
}
export function validateBindings(bindings?: AppBinding[]) {
filterInvalidChannelHeaderBindings(bindings);
filterInvalidCommands(bindings);
filterInvalidPostMenuBindings(bindings);
bindings?.forEach(fillAndTrimBindingsInformation);
export function validateBindings(bindings: AppBinding[] = []): AppBinding[] {
const channelHeaderBindings = bindings?.filter((b) => b.location === AppBindingLocations.CHANNEL_HEADER_ICON);
const postMenuBindings = bindings?.filter((b) => b.location === AppBindingLocations.POST_MENU_ITEM);
const commandBindings = bindings?.filter((b) => b.location === AppBindingLocations.COMMAND);
channelHeaderBindings.forEach((b) => cleanBinding(b, AppBindingLocations.CHANNEL_HEADER_ICON));
postMenuBindings.forEach((b) => cleanBinding(b, AppBindingLocations.POST_MENU_ITEM));
commandBindings.forEach((b) => cleanBinding(b, AppBindingLocations.COMMAND));
const hasBindings = (b: AppBinding) => b.bindings?.length;
return postMenuBindings.filter(hasBindings).concat(channelHeaderBindings.filter(hasBindings), commandBindings.filter(hasBindings));
}
// filterInvalidCommands remove commands without a label
function filterInvalidCommands(bindings?: AppBinding[]) {
if (!bindings) {
export function cleanForm(form?: AppForm): void {
if (!form) {
return;
}
const isValidCommand = (b: AppBinding): boolean => {
return Boolean(b.label);
};
const toRemove: number[] = [];
const usedLabels: {[label: string]: boolean} = {};
form.fields?.forEach((field, i) => {
if (!field.name) {
toRemove.unshift(i);
return;
}
const validateCommand = (b: AppBinding) => {
b.bindings = b.bindings?.filter(isValidCommand);
b.bindings?.forEach(validateCommand);
};
if (field.name.match(/ |\t/)) {
toRemove.unshift(i);
return;
}
bindings?.filter((b) => b.location === AppBindingLocations.COMMAND).forEach(validateCommand);
let label = field.label;
if (!label) {
label = field.name;
}
if (label.match(/ |\t/)) {
toRemove.unshift(i);
return;
}
if (usedLabels[label]) {
toRemove.unshift(i);
return;
}
if (field.type === AppFieldTypes.STATIC_SELECT) {
cleanStaticSelect(field);
if (!field.options?.length) {
toRemove.unshift(i);
return;
}
}
usedLabels[label] = true;
});
toRemove.forEach((i) => {
form.fields.splice(i, 1);
});
}
// filterInvalidChannelHeaderBindings remove bindings
// without a label.
function filterInvalidChannelHeaderBindings(bindings?: AppBinding[]) {
if (!bindings) {
return;
}
function cleanStaticSelect(field: AppField): void {
const toRemove: number[] = [];
const usedLabels: {[label: string]: boolean} = {};
const usedValues: {[label: string]: boolean} = {};
field.options?.forEach((option, i) => {
let label = option.label;
if (!label) {
label = option.value;
}
const isValidChannelHeaderBindings = (b: AppBinding): boolean => {
return Boolean(b.label);
};
if (!label) {
toRemove.unshift(i);
return;
}
const validateChannelHeaderBinding = (b: AppBinding) => {
b.bindings = b.bindings?.filter(isValidChannelHeaderBindings);
b.bindings?.forEach(validateChannelHeaderBinding);
};
if (usedLabels[label]) {
toRemove.unshift(i);
return;
}
bindings?.filter((b) => b.location === AppBindingLocations.CHANNEL_HEADER_ICON).forEach(validateChannelHeaderBinding);
}
if (usedValues[option.value]) {
toRemove.unshift(i);
return;
}
// filterInvalidPostMenuBindings remove bindings
// without a label.
function filterInvalidPostMenuBindings(bindings?: AppBinding[]) {
if (!bindings) {
return;
}
usedLabels[label] = true;
usedValues[option.value] = true;
});
const isValidPostMenuBinding = (b: AppBinding): boolean => {
return Boolean(b.label);
};
const validatePostMenuBinding = (b: AppBinding) => {
b.bindings = b.bindings?.filter(isValidPostMenuBinding);
b.bindings?.forEach(validatePostMenuBinding);
};
bindings?.filter((b) => b.location === AppBindingLocations.POST_MENU_ITEM).forEach(validatePostMenuBinding);
toRemove.forEach((i) => {
field.options?.splice(i, 1);
});
}
export function createCallContext(
@ -179,3 +247,5 @@ export const makeCallErrorResponse = (errMessage: string) => {
error: errMessage,
};
};
export const filterEmptyOptions = (option: AppSelectOption): boolean => Boolean(option.value && !option.value.match(/^[ \t]+$/));