Doug Lauder
2023-03-22 17:22:27 -04:00
коммит произвёл GitHub
родитель b61c096497
Коммит c943ed6859
13276 изменённых файлов: 1695615 добавлений и 223189 удалений

Просмотреть файл

@@ -0,0 +1,942 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/AbstractCommand should match snapshot 1`] = `
<div
className="backstage-content row"
>
<BackstageHeader>
<Link
to="/test/integrations/commands"
>
<MemoizedFormattedMessage
defaultMessage="Slash Commands"
id="installed_command.header"
/>
</Link>
<MemoizedFormattedMessage
defaultMessage="Header"
id="Header"
/>
</BackstageHeader>
<div
className="backstage-form"
>
<form
className="form-horizontal"
onSubmit={[Function]}
>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="displayName"
>
<MemoizedFormattedMessage
defaultMessage="Title"
id="add_command.displayName"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="displayName"
maxLength="64"
onChange={[Function]}
type="text"
value="display_name"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Specify a title, of up to 64 characters, for the slash command settings page."
id="add_command.displayName.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="description"
>
<MemoizedFormattedMessage
defaultMessage="Description"
id="add_command.description"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="description"
maxLength="128"
onChange={[Function]}
type="text"
value="description"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Describe your slash command."
id="add_command.description.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="trigger"
>
<MemoizedFormattedMessage
defaultMessage="Command Trigger Word"
id="add_command.trigger"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<LocalizedInput
className="form-control"
id="trigger"
maxLength={128}
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "Command trigger e.g. \\"hello\\" not including the slash",
"id": "add_command.trigger.placeholder",
}
}
type="text"
value="trigger"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Specify a trigger word that is not a built-in command, does not contain spaces, and does not begin with the slash character."
id="add_command.trigger.help"
/>
</div>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Examples: client, employee, patient, weather"
id="add_command.trigger.helpExamples"
/>
</div>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Reserved: {link}"
id="add_command.trigger.helpReserved"
values={
Object {
"link": <ExternalLink
href="https://developers.mattermost.com/integrate/admin-guide/admin-slash-commands/#built-in-commands"
location="abstract_command"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="See built-in slash commands"
id="add_command.trigger.helpReservedLinkText"
/>
</ExternalLink>,
}
}
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="url"
>
<MemoizedFormattedMessage
defaultMessage="Request URL"
id="add_command.url"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<LocalizedInput
className="form-control"
id="url"
maxLength="1024"
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "Must start with http:// or https://",
"id": "add_command.url.placeholder",
}
}
type="text"
value="https://google.com/command"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Specify the callback URL to receive the HTTP POST or GET event request when the slash command is run."
id="add_command.url.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="method"
>
<MemoizedFormattedMessage
defaultMessage="Request Method"
id="add_command.method"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<select
className="form-control"
id="method"
onChange={[Function]}
value="G"
>
<option
value="P"
>
POST
</option>
<option
value="G"
>
GET
</option>
</select>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Specify the type of request, either POST or GET, sent to the endpoint that Mattermost hits to reach your application."
id="add_command.method.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="username"
>
<MemoizedFormattedMessage
defaultMessage="Response Username"
id="add_command.username"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<LocalizedInput
className="form-control"
id="username"
maxLength="64"
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "Username",
"id": "add_command.username.placeholder",
}
}
type="text"
value="username"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="(Optional) Specify the name to use when posting responses for this slash command. Usernames can be up to 22 characters, and contain lowercase letters, numbers, and the symbols \\\\\\"-\\\\\\", \\\\\\"_\\\\\\", and \\\\\\".\\\\\\". If left blank, your Mattermost username is used."
id="add_command.username.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="iconUrl"
>
<MemoizedFormattedMessage
defaultMessage="Response Icon"
id="add_command.iconUrl"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<LocalizedInput
className="form-control"
id="iconUrl"
maxLength="1024"
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "https://www.example.com/myicon.png",
"id": "add_command.iconUrl.placeholder",
}
}
type="text"
value="https://google.com/icon"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="(Optional) Enter the URL of a .png or .jpg file to use as the icon when posting responses to this slash command. The file must be at least 128 pixels by 128 pixels. If left blank, your profile picture is used."
id="add_command.iconUrl.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="autocomplete"
>
<MemoizedFormattedMessage
defaultMessage="Autocomplete"
id="add_command.autocomplete"
/>
</label>
<div
className="col-md-5 col-sm-8 checkbox"
>
<input
checked={true}
id="autocomplete"
onChange={[Function]}
type="checkbox"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="(Optional) Show your slash command on the autocomplete list when someone types \\"/\\" in the input box."
id="add_command.autocomplete.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="autocompleteHint"
>
<MemoizedFormattedMessage
defaultMessage="Autocomplete Hint"
id="add_command.autocompleteHint"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<LocalizedInput
className="form-control"
id="autocompleteHint"
maxLength="1024"
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "Example: [Patient Name]",
"id": "add_command.autocompleteHint.placeholder",
}
}
type="text"
value="auto_complete_hint"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="(Optional) Specify the arguments associated with your slash command. These are displayed as help on the autocomplete list."
id="add_command.autocompleteHint.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="autocompleteDescription"
>
<MemoizedFormattedMessage
defaultMessage="Autocomplete Description"
id="add_command.autocompleteDescription"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<LocalizedInput
className="form-control"
id="description"
maxLength="128"
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "Example: \\"Returns search results for patient records\\"",
"id": "add_command.autocompleteDescription.placeholder",
}
}
type="text"
value="auto_complete_desc"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="(Optional) Describe your slash command for the autocomplete list."
id="add_command.autocompleteDescription.help"
/>
</div>
</div>
</div>
<div
className="backstage-form__footer"
>
<FormError
error={null}
errors={
Array [
"",
null,
]
}
type="backstage"
/>
<Link
className="btn btn-link btn-sm"
to="/test/integrations/commands"
>
<MemoizedFormattedMessage
defaultMessage="Cancel"
id="add_command.cancel"
/>
</Link>
<SpinnerButton
className="btn btn-primary"
id="saveCommand"
onClick={[Function]}
spinning={false}
spinningText="Loading"
type="submit"
>
<MemoizedFormattedMessage
defaultMessage="Footer"
id="Footer"
/>
</SpinnerButton>
renderExtra
</div>
</form>
</div>
</div>
`;
exports[`components/integrations/AbstractCommand should match snapshot, displays client error 1`] = `
<div
className="backstage-content row"
>
<BackstageHeader>
<Link
to="/test/integrations/commands"
>
<MemoizedFormattedMessage
defaultMessage="Slash Commands"
id="installed_command.header"
/>
</Link>
<MemoizedFormattedMessage
defaultMessage="Header"
id="Header"
/>
</BackstageHeader>
<div
className="backstage-form"
>
<form
className="form-horizontal"
onSubmit={[Function]}
>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="displayName"
>
<MemoizedFormattedMessage
defaultMessage="Title"
id="add_command.displayName"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="displayName"
maxLength="64"
onChange={[Function]}
type="text"
value="display_name"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Specify a title, of up to 64 characters, for the slash command settings page."
id="add_command.displayName.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="description"
>
<MemoizedFormattedMessage
defaultMessage="Description"
id="add_command.description"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="description"
maxLength="128"
onChange={[Function]}
type="text"
value="description"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Describe your slash command."
id="add_command.description.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="trigger"
>
<MemoizedFormattedMessage
defaultMessage="Command Trigger Word"
id="add_command.trigger"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<LocalizedInput
className="form-control"
id="trigger"
maxLength={128}
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "Command trigger e.g. \\"hello\\" not including the slash",
"id": "add_command.trigger.placeholder",
}
}
type="text"
value=""
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Specify a trigger word that is not a built-in command, does not contain spaces, and does not begin with the slash character."
id="add_command.trigger.help"
/>
</div>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Examples: client, employee, patient, weather"
id="add_command.trigger.helpExamples"
/>
</div>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Reserved: {link}"
id="add_command.trigger.helpReserved"
values={
Object {
"link": <ExternalLink
href="https://developers.mattermost.com/integrate/admin-guide/admin-slash-commands/#built-in-commands"
location="abstract_command"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="See built-in slash commands"
id="add_command.trigger.helpReservedLinkText"
/>
</ExternalLink>,
}
}
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="url"
>
<MemoizedFormattedMessage
defaultMessage="Request URL"
id="add_command.url"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<LocalizedInput
className="form-control"
id="url"
maxLength="1024"
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "Must start with http:// or https://",
"id": "add_command.url.placeholder",
}
}
type="text"
value="https://google.com/command"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Specify the callback URL to receive the HTTP POST or GET event request when the slash command is run."
id="add_command.url.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="method"
>
<MemoizedFormattedMessage
defaultMessage="Request Method"
id="add_command.method"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<select
className="form-control"
id="method"
onChange={[Function]}
value="G"
>
<option
value="P"
>
POST
</option>
<option
value="G"
>
GET
</option>
</select>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Specify the type of request, either POST or GET, sent to the endpoint that Mattermost hits to reach your application."
id="add_command.method.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="username"
>
<MemoizedFormattedMessage
defaultMessage="Response Username"
id="add_command.username"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<LocalizedInput
className="form-control"
id="username"
maxLength="64"
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "Username",
"id": "add_command.username.placeholder",
}
}
type="text"
value="username"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="(Optional) Specify the name to use when posting responses for this slash command. Usernames can be up to 22 characters, and contain lowercase letters, numbers, and the symbols \\\\\\"-\\\\\\", \\\\\\"_\\\\\\", and \\\\\\".\\\\\\". If left blank, your Mattermost username is used."
id="add_command.username.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="iconUrl"
>
<MemoizedFormattedMessage
defaultMessage="Response Icon"
id="add_command.iconUrl"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<LocalizedInput
className="form-control"
id="iconUrl"
maxLength="1024"
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "https://www.example.com/myicon.png",
"id": "add_command.iconUrl.placeholder",
}
}
type="text"
value="https://google.com/icon"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="(Optional) Enter the URL of a .png or .jpg file to use as the icon when posting responses to this slash command. The file must be at least 128 pixels by 128 pixels. If left blank, your profile picture is used."
id="add_command.iconUrl.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="autocomplete"
>
<MemoizedFormattedMessage
defaultMessage="Autocomplete"
id="add_command.autocomplete"
/>
</label>
<div
className="col-md-5 col-sm-8 checkbox"
>
<input
checked={true}
id="autocomplete"
onChange={[Function]}
type="checkbox"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="(Optional) Show your slash command on the autocomplete list when someone types \\"/\\" in the input box."
id="add_command.autocomplete.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="autocompleteHint"
>
<MemoizedFormattedMessage
defaultMessage="Autocomplete Hint"
id="add_command.autocompleteHint"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<LocalizedInput
className="form-control"
id="autocompleteHint"
maxLength="1024"
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "Example: [Patient Name]",
"id": "add_command.autocompleteHint.placeholder",
}
}
type="text"
value="auto_complete_hint"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="(Optional) Specify the arguments associated with your slash command. These are displayed as help on the autocomplete list."
id="add_command.autocompleteHint.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="autocompleteDescription"
>
<MemoizedFormattedMessage
defaultMessage="Autocomplete Description"
id="add_command.autocompleteDescription"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<LocalizedInput
className="form-control"
id="description"
maxLength="128"
onChange={[Function]}
placeholder={
Object {
"defaultMessage": "Example: \\"Returns search results for patient records\\"",
"id": "add_command.autocompleteDescription.placeholder",
}
}
type="text"
value="auto_complete_desc"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="(Optional) Describe your slash command for the autocomplete list."
id="add_command.autocompleteDescription.help"
/>
</div>
</div>
</div>
<div
className="backstage-form__footer"
>
<FormError
error={null}
errors={
Array [
"server error",
<Memo(MemoizedFormattedMessage)
defaultMessage="A trigger word is required"
id="add_command.triggerRequired"
/>,
]
}
type="backstage"
/>
<Link
className="btn btn-link btn-sm"
to="/test/integrations/commands"
>
<MemoizedFormattedMessage
defaultMessage="Cancel"
id="add_command.cancel"
/>
</Link>
<SpinnerButton
className="btn btn-primary"
id="saveCommand"
onClick={[Function]}
spinning={false}
spinningText="Loading"
type="submit"
>
<MemoizedFormattedMessage
defaultMessage="Footer"
id="Footer"
/>
</SpinnerButton>
renderExtra
</div>
</form>
</div>
</div>
`;

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -0,0 +1,609 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/AbstractOAuthApp should match snapshot 1`] = `
<div
className="backstage-content"
>
<BackstageHeader>
<Link
to="/test/integrations/oauth2-apps"
>
<MemoizedFormattedMessage
defaultMessage="Installed OAuth2 Apps"
id="installed_oauth_apps.header"
/>
</Link>
<MemoizedFormattedMessage
defaultMessage="Header"
id="Header"
/>
</BackstageHeader>
<div
className="backstage-form"
>
<div
className="integration__icon"
>
<img
alt="integration icon"
src="https://test.com/icon"
/>
</div>
<form
className="form-horizontal"
>
<Connect(SystemPermissionGate)
permissions={
Array [
"manage_system",
]
}
>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="is_trusted"
>
<MemoizedFormattedMessage
defaultMessage="Is Trusted"
id="installed_oauth_apps.trusted"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<label
className="radio-inline"
>
<input
checked={true}
name="is_trusted"
onChange={[Function]}
type="radio"
value="true"
/>
<MemoizedFormattedMessage
defaultMessage="Yes"
id="installed_oauth_apps.trusted.yes"
/>
</label>
<label
className="radio-inline"
>
<input
checked={false}
name="is_trusted"
onChange={[Function]}
type="radio"
value="false"
/>
<MemoizedFormattedMessage
defaultMessage="No"
id="installed_oauth_apps.trusted.no"
/>
</label>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="If true, the OAuth 2.0 application is considered trusted by the Mattermost server and does not require the user to accept authorization. If false, a window opens to ask the user to accept or deny the authorization."
id="add_oauth_app.trusted.help"
/>
</div>
</div>
</div>
</Connect(SystemPermissionGate)>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="name"
>
<MemoizedFormattedMessage
defaultMessage="Display Name"
id="installed_oauth_apps.name"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="name"
maxLength="64"
onChange={[Function]}
type="text"
value="testApp"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Specify the display name, of up to 64 characters, for your OAuth 2.0 application."
id="add_oauth_app.name.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="description"
>
<MemoizedFormattedMessage
defaultMessage="Description"
id="installed_oauth_apps.description"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="description"
maxLength="512"
onChange={[Function]}
type="text"
value="testing"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Describe your OAuth 2.0 application."
id="add_oauth_app.description.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="homepage"
>
<MemoizedFormattedMessage
defaultMessage="Homepage"
id="installed_oauth_apps.homepage"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="homepage"
maxLength="256"
onChange={[Function]}
type="url"
value="https://test.com"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="This is the URL for the homepage of the OAuth 2.0 application. Depending on your server configuration, use HTTP or HTTPS in the URL."
id="add_oauth_app.homepage.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="icon_url"
>
<MemoizedFormattedMessage
defaultMessage="Icon URL"
id="installed_oauth_apps.iconUrl"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="icon_url"
maxLength="512"
onChange={[Function]}
type="url"
value="https://test.com/icon"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="(Optional) The URL of the image used for your OAuth 2.0 application. Make sure you use HTTP or HTTPS in your URL."
id="add_oauth_app.icon.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="callbackUrls"
>
<MemoizedFormattedMessage
defaultMessage="Callback URLs (One Per Line)"
id="installed_oauth_apps.callbackUrls"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<textarea
className="form-control"
id="callbackUrls"
maxLength="1024"
onChange={[Function]}
rows="3"
value="https://test.com/callback
https://test.com/callback2"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="The redirect URIs to which the service will redirect users after accepting or denying authorization of your application, and which will handle authorization codes or access tokens. Must be a valid URL and start with http:// or https://."
id="add_oauth_app.callbackUrls.help"
/>
</div>
</div>
</div>
<div
className="backstage-form__footer"
>
<FormError
error={null}
errors={
Array [
"",
null,
]
}
type="backstage"
/>
<Link
className="btn btn-link btn-sm"
to="/test/integrations/oauth2-apps"
>
<MemoizedFormattedMessage
defaultMessage="Cancel"
id="installed_oauth_apps.cancel"
/>
</Link>
<SpinnerButton
className="btn btn-primary"
id="saveOauthApp"
onClick={[Function]}
spinning={false}
spinningText="Loading"
type="submit"
>
<MemoizedFormattedMessage
defaultMessage="Footer"
id="Footer"
/>
</SpinnerButton>
renderExtra
</div>
</form>
</div>
</div>
`;
exports[`components/integrations/AbstractOAuthApp should match snapshot, displays client error 1`] = `
<div
className="backstage-content"
>
<BackstageHeader>
<Link
to="/test/integrations/oauth2-apps"
>
<MemoizedFormattedMessage
defaultMessage="Installed OAuth2 Apps"
id="installed_oauth_apps.header"
/>
</Link>
<MemoizedFormattedMessage
defaultMessage="Header"
id="Header"
/>
</BackstageHeader>
<div
className="backstage-form"
>
<div
className="integration__icon"
>
<img
alt="integration icon"
src="https://test.com/icon"
/>
</div>
<form
className="form-horizontal"
>
<Connect(SystemPermissionGate)
permissions={
Array [
"manage_system",
]
}
>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="is_trusted"
>
<MemoizedFormattedMessage
defaultMessage="Is Trusted"
id="installed_oauth_apps.trusted"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<label
className="radio-inline"
>
<input
checked={true}
name="is_trusted"
onChange={[Function]}
type="radio"
value="true"
/>
<MemoizedFormattedMessage
defaultMessage="Yes"
id="installed_oauth_apps.trusted.yes"
/>
</label>
<label
className="radio-inline"
>
<input
checked={false}
name="is_trusted"
onChange={[Function]}
type="radio"
value="false"
/>
<MemoizedFormattedMessage
defaultMessage="No"
id="installed_oauth_apps.trusted.no"
/>
</label>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="If true, the OAuth 2.0 application is considered trusted by the Mattermost server and does not require the user to accept authorization. If false, a window opens to ask the user to accept or deny the authorization."
id="add_oauth_app.trusted.help"
/>
</div>
</div>
</div>
</Connect(SystemPermissionGate)>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="name"
>
<MemoizedFormattedMessage
defaultMessage="Display Name"
id="installed_oauth_apps.name"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="name"
maxLength="64"
onChange={[Function]}
type="text"
value="testApp"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Specify the display name, of up to 64 characters, for your OAuth 2.0 application."
id="add_oauth_app.name.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="description"
>
<MemoizedFormattedMessage
defaultMessage="Description"
id="installed_oauth_apps.description"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="description"
maxLength="512"
onChange={[Function]}
type="text"
value="testing"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Describe your OAuth 2.0 application."
id="add_oauth_app.description.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="homepage"
>
<MemoizedFormattedMessage
defaultMessage="Homepage"
id="installed_oauth_apps.homepage"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="homepage"
maxLength="256"
onChange={[Function]}
type="url"
value="https://test.com"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="This is the URL for the homepage of the OAuth 2.0 application. Depending on your server configuration, use HTTP or HTTPS in the URL."
id="add_oauth_app.homepage.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="icon_url"
>
<MemoizedFormattedMessage
defaultMessage="Icon URL"
id="installed_oauth_apps.iconUrl"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="icon_url"
maxLength="512"
onChange={[Function]}
type="url"
value="https://test.com/icon"
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="(Optional) The URL of the image used for your OAuth 2.0 application. Make sure you use HTTP or HTTPS in your URL."
id="add_oauth_app.icon.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="callbackUrls"
>
<MemoizedFormattedMessage
defaultMessage="Callback URLs (One Per Line)"
id="installed_oauth_apps.callbackUrls"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<textarea
className="form-control"
id="callbackUrls"
maxLength="1024"
onChange={[Function]}
rows="3"
value=""
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="The redirect URIs to which the service will redirect users after accepting or denying authorization of your application, and which will handle authorization codes or access tokens. Must be a valid URL and start with http:// or https://."
id="add_oauth_app.callbackUrls.help"
/>
</div>
</div>
</div>
<div
className="backstage-form__footer"
>
<FormError
error={null}
errors={
Array [
"serverError",
<Memo(MemoizedFormattedMessage)
defaultMessage="One or more callback URLs are required."
id="add_oauth_app.callbackUrlsRequired"
/>,
]
}
type="backstage"
/>
<Link
className="btn btn-link btn-sm"
to="/test/integrations/oauth2-apps"
>
<MemoizedFormattedMessage
defaultMessage="Cancel"
id="installed_oauth_apps.cancel"
/>
</Link>
<SpinnerButton
className="btn btn-primary"
id="saveOauthApp"
onClick={[Function]}
spinning={false}
spinningText="Loading"
type="submit"
>
<MemoizedFormattedMessage
defaultMessage="Footer"
id="Footer"
/>
</SpinnerButton>
renderExtra
</div>
</form>
</div>
</div>
`;

Просмотреть файл

@@ -0,0 +1,341 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/AbstractOutgoingWebhook should match snapshot 1`] = `
<div
className="backstage-content"
>
<BackstageHeader>
<Link
to="/test/integrations/outgoing_webhooks"
>
<MemoizedFormattedMessage
defaultMessage="Outgoing Webhooks"
id="installed_outgoing_webhooks.header"
/>
</Link>
<MemoizedFormattedMessage
defaultMessage="add"
id="add"
/>
</BackstageHeader>
<div
className="backstage-form"
>
<form
className="form-horizontal"
onSubmit={[Function]}
>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="displayName"
>
<MemoizedFormattedMessage
defaultMessage="Title"
id="add_outgoing_webhook.displayName"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="displayName"
maxLength="64"
onChange={[Function]}
type="text"
value=""
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Specify a title, of up to 64 characters, for the webhook settings page."
id="add_outgoing_webhook.displayName.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="description"
>
<MemoizedFormattedMessage
defaultMessage="Description"
id="add_outgoing_webhook.description"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<input
className="form-control"
id="description"
maxLength="500"
onChange={[Function]}
type="text"
value=""
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Describe your outgoing webhook."
id="add_outgoing_webhook.description.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="contentType"
>
<MemoizedFormattedMessage
defaultMessage="Content Type"
id="add_outgoing_webhook.content_Type"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<select
className="form-control"
onChange={[Function]}
value="application/x-www-form-urlencoded"
>
<option
value="application/x-www-form-urlencoded"
>
application/x-www-form-urlencoded
</option>
<option
value="application/json"
>
application/json
</option>
</select>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Specify the content type by which to send the request."
id="add_outgoing_webhook.contentType.help1"
/>
</div>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="For the server to encode the parameters in a URL format in the request body, select application/x-www-form-urlencoded."
id="add_outgoing_webhook.contentType.help2"
/>
</div>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="For the server to format the request body as JSON, select application/json."
id="add_outgoing_webhook.contentType.help3"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="channelId"
>
<MemoizedFormattedMessage
defaultMessage="Channel"
id="add_outgoing_webhook.channel"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<Connect(ChannelSelect)
id="channelId"
onChange={[Function]}
selectOpen={true}
value=""
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="This field is optional if you specify at least one trigger word. Specify the public channel that delivers the payload to the webhook."
id="add_outgoing_webhook.channel.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="triggerWords"
>
<MemoizedFormattedMessage
defaultMessage="Trigger Words (One Per Line)"
id="add_outgoing_webhook.triggerWords"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<textarea
className="form-control"
id="triggerWords"
maxLength="1000"
onChange={[Function]}
rows="3"
value=""
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Specify the trigger words that send an HTTP POST request to your application. The trigger can be for the channel, the outgoing webhook, or both. If you select only Channel, trigger words are optional. If you select both, the message must match both values."
id="add_outgoing_webhook.triggerWords.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="triggerWords"
>
<MemoizedFormattedMessage
defaultMessage="Trigger When"
id="add_outgoing_webhook.triggerWordsTriggerWhen"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<select
className="form-control"
onChange={[Function]}
value={0}
>
<option
value="0"
>
First word matches a trigger word exactly
</option>
<option
value="1"
>
First word starts with a trigger word
</option>
</select>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Specify when to trigger the outgoing webhook."
id="add_outgoing_webhook.triggerWordsTriggerWhen.help"
/>
</div>
</div>
</div>
<div
className="form-group"
>
<label
className="control-label col-sm-4"
htmlFor="callbackUrls"
>
<MemoizedFormattedMessage
defaultMessage="Callback URLs (One Per Line)"
id="add_outgoing_webhook.callbackUrls"
/>
</label>
<div
className="col-md-5 col-sm-8"
>
<textarea
className="form-control"
id="callbackUrls"
maxLength="1000"
onChange={[Function]}
rows="3"
value=""
/>
<div
className="form__help"
>
<MemoizedFormattedMessage
defaultMessage="Specify the URL that the messages will be sent to. If the URL is private, add it as a {link}."
id="add_outgoing_webhook.callbackUrls.help"
values={
Object {
"link": <ExternalLink
href="https://docs.mattermost.com/configure/configuration-settings.html#session-lengths"
location="abstract_outgoing_webhook"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="trusted internal connection"
id="add_outgoing_webhook.callbackUrls.helpLinkText"
/>
</ExternalLink>,
}
}
/>
</div>
</div>
</div>
<div
className="backstage-form__footer"
>
<FormError
error={null}
errors={
Array [
"",
null,
]
}
type="backstage"
/>
<Link
className="btn btn-link btn-sm"
to="/test/integrations/outgoing_webhooks"
>
<MemoizedFormattedMessage
defaultMessage="Cancel"
id="add_outgoing_webhook.cancel"
/>
</Link>
<SpinnerButton
className="btn btn-primary"
id="saveWebhook"
onClick={[Function]}
spinning={false}
spinningText="loading"
type="submit"
>
<MemoizedFormattedMessage
defaultMessage="save"
id="save"
/>
</SpinnerButton>
</div>
</form>
</div>
</div>
`;

Просмотреть файл

@@ -0,0 +1,371 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/InstalledCommand should call onDelete function 1`] = `
<div
className="backstage-list__item"
>
<div
className="item-details"
>
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<div>
<strong
className="item-details__name"
>
display_name
</strong>
<span
className="item-details__trigger"
>
- /trigger auto_complete_hint
</span>
</div>
<div
className="item-actions"
>
<button
className="style--none color--link"
onClick={[Function]}
>
<MemoizedFormattedMessage
defaultMessage="Regenerate Token"
id="installed_integrations.regenToken"
/>
</button>
-
<Link
to="/team_name/integrations/commands/edit?id=r5tpgt4iepf45jt768jz84djic"
>
<MemoizedFormattedMessage
defaultMessage="Edit"
id="installed_integrations.edit"
/>
</Link>
-
<Connect(DeleteIntegrationLink)
modalMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="This action permanently deletes the slash command and breaks any integrations using it. Are you sure you want to delete it?"
id="installed_commands.delete.confirm"
/>
}
onDelete={[Function]}
/>
</div>
</div>
<div
className="item-details__row"
>
<span
className="item-details__description"
>
description
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__token"
>
<MemoizedFormattedMessage
defaultMessage="Token: {token}"
id="installed_integrations.token"
values={
Object {
"token": "testToken",
}
}
/>
<CopyText
defaultMessage="Copy"
idMessage="integrations.copy"
value="testToken"
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__creation"
>
<MemoizedFormattedMessage
defaultMessage="Created by {creator} on {createAt, date, full}"
id="installed_integrations.creation"
values={
Object {
"createAt": 1499722850203,
"creator": "username",
}
}
/>
</span>
</div>
</div>
</div>
`;
exports[`components/integrations/InstalledCommand should call onRegenToken function 1`] = `
<div
className="backstage-list__item"
>
<div
className="item-details"
>
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<div>
<strong
className="item-details__name"
>
display_name
</strong>
<span
className="item-details__trigger"
>
- /trigger auto_complete_hint
</span>
</div>
<div
className="item-actions"
>
<button
className="style--none color--link"
onClick={[Function]}
>
<MemoizedFormattedMessage
defaultMessage="Regenerate Token"
id="installed_integrations.regenToken"
/>
</button>
-
<Link
to="/team_name/integrations/commands/edit?id=r5tpgt4iepf45jt768jz84djic"
>
<MemoizedFormattedMessage
defaultMessage="Edit"
id="installed_integrations.edit"
/>
</Link>
-
<Connect(DeleteIntegrationLink)
modalMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="This action permanently deletes the slash command and breaks any integrations using it. Are you sure you want to delete it?"
id="installed_commands.delete.confirm"
/>
}
onDelete={[Function]}
/>
</div>
</div>
<div
className="item-details__row"
>
<span
className="item-details__description"
>
description
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__token"
>
<MemoizedFormattedMessage
defaultMessage="Token: {token}"
id="installed_integrations.token"
values={
Object {
"token": "testToken",
}
}
/>
<CopyText
defaultMessage="Copy"
idMessage="integrations.copy"
value="testToken"
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__creation"
>
<MemoizedFormattedMessage
defaultMessage="Created by {creator} on {createAt, date, full}"
id="installed_integrations.creation"
values={
Object {
"createAt": 1499722850203,
"creator": "username",
}
}
/>
</span>
</div>
</div>
</div>
`;
exports[`components/integrations/InstalledCommand should filter out command 1`] = `""`;
exports[`components/integrations/InstalledCommand should match snapshot 1`] = `
<div
className="backstage-list__item"
>
<div
className="item-details"
>
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<div>
<strong
className="item-details__name"
>
display_name
</strong>
<span
className="item-details__trigger"
>
- /trigger auto_complete_hint
</span>
</div>
</div>
<div
className="item-details__row"
>
<span
className="item-details__description"
>
description
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__token"
>
<MemoizedFormattedMessage
defaultMessage="Token: {token}"
id="installed_integrations.token"
values={
Object {
"token": "testToken",
}
}
/>
<CopyText
defaultMessage="Copy"
idMessage="integrations.copy"
value="testToken"
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__creation"
>
<MemoizedFormattedMessage
defaultMessage="Created by {creator} on {createAt, date, full}"
id="installed_integrations.creation"
values={
Object {
"createAt": 1499722850203,
"creator": "username",
}
}
/>
</span>
</div>
</div>
</div>
`;
exports[`components/integrations/InstalledCommand should match snapshot, not autocomplete, no display_name/description/auto_complete_hint 1`] = `
<div
className="backstage-list__item"
>
<div
className="item-details"
>
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<div>
<strong
className="item-details__name"
>
command_display_name
</strong>
<span
className="item-details__trigger"
>
- /trigger
</span>
</div>
</div>
<div
className="item-details__row"
>
<span
className="item-details__description"
>
command_description
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__token"
>
<MemoizedFormattedMessage
defaultMessage="Token: {token}"
id="installed_integrations.token"
values={
Object {
"token": "testToken",
}
}
/>
<CopyText
defaultMessage="Copy"
idMessage="integrations.copy"
value="testToken"
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__creation"
>
<MemoizedFormattedMessage
defaultMessage="Created by {creator} on {createAt, date, full}"
id="installed_integrations.creation"
values={
Object {
"createAt": 1499722850203,
"creator": "username",
}
}
/>
</span>
</div>
</div>
</div>
`;

Просмотреть файл

@@ -0,0 +1,94 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/InstalledIncomingWebhook should match snapshot 1`] = `
<div
className="backstage-list__item"
>
<div
className="item-details"
>
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<strong
className="item-details__name"
>
build
</strong>
<div
className="item-actions"
>
<Link
to="/test/integrations/incoming_webhooks/edit?id=9w96t4nhbfdiij64wfqors4i1r"
>
<MemoizedFormattedMessage
defaultMessage="Edit"
id="installed_integrations.edit"
/>
</Link>
-
<Connect(DeleteIntegrationLink)
modalMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="This action permanently deletes the incoming webhook and breaks any integrations using it. Are you sure you want to delete it?"
id="installed_incoming_webhooks.delete.confirm"
/>
}
onDelete={[Function]}
/>
</div>
</div>
<div
className="item-details__row"
>
<span
className="item-details__description"
>
build status
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__url word-break--all"
>
<MemoizedFormattedMessage
defaultMessage="URL: {url}"
id="installed_integrations.url"
values={
Object {
"url": "http://localhost:8065/hooks/9w96t4nhbfdiij64wfqors4i1r",
}
}
/>
<span>
<CopyText
defaultMessage="Copy"
idMessage="integrations.copy"
value="http://localhost:8065/hooks/9w96t4nhbfdiij64wfqors4i1r"
/>
</span>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__creation"
>
<MemoizedFormattedMessage
defaultMessage="Created by {creator} on {createAt, date, full}"
id="installed_integrations.creation"
values={
Object {
"createAt": 1502455422406,
"creator": "creator",
}
}
/>
</span>
</div>
</div>
</div>
`;

Просмотреть файл

@@ -0,0 +1,184 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/InstalledOutgoingWebhook Should match snapshot of makeDisplayName 1`] = `"hook display name"`;
exports[`components/integrations/InstalledOutgoingWebhook Should match snapshot of makeDisplayName 2`] = `"channel display name"`;
exports[`components/integrations/InstalledOutgoingWebhook Should match snapshot of makeDisplayName 3`] = `
<Memo(MemoizedFormattedMessage)
defaultMessage="A Private Webhook"
id="installed_outgoing_webhooks.unknown_channel"
/>
`;
exports[`components/integrations/InstalledOutgoingWebhook should match snapshot 1`] = `
<div
className="backstage-list__item"
>
<div
className="item-details"
>
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<strong
className="item-details__name"
>
build
</strong>
<div
className="item-actions"
>
<button
className="style--none color--link"
onClick={[Function]}
>
<MemoizedFormattedMessage
defaultMessage="Regen Token"
id="installed_integrations.regenToken"
/>
</button>
-
<Link
to="/test/integrations/outgoing_webhooks/edit?id=7h88x419ubbyuxzs7dfwtgkffr"
>
<MemoizedFormattedMessage
defaultMessage="Edit"
id="installed_integrations.edit"
/>
</Link>
-
<Connect(DeleteIntegrationLink)
modalMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="This action permanently deletes the outgoing webhook and breaks any integrations using it. Are you sure you want to delete it?"
id="installed_outgoing_webhooks.delete.confirm"
/>
}
onDelete={[Function]}
/>
</div>
</div>
<div
className="item-details__row"
>
<span
className="item-details__description"
>
build status
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__content_type"
>
<MemoizedFormattedMessage
defaultMessage="Content-Type: {contentType}"
id="installed_integrations.content_type"
values={
Object {
"contentType": "application/x-www-form-urlencoded",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__trigger-words"
>
<MemoizedFormattedMessage
defaultMessage="Trigger Words: {triggerWords}"
id="installed_integrations.triggerWords"
values={
Object {
"triggerWords": "build",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__trigger-when"
>
<MemoizedFormattedMessage
defaultMessage="Trigger When: {triggerWhen}"
id="installed_integrations.triggerWhen"
values={
Object {
"triggerWhen": <Memo(MemoizedFormattedMessage)
defaultMessage="First word matches a trigger word exactly"
id="add_outgoing_webhook.triggerWordsTriggerWhenFullWord"
/>,
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__token"
>
<MemoizedFormattedMessage
defaultMessage="Token: {token}"
id="installed_integrations.token"
values={
Object {
"token": "xoxz1z7c3tgi9xhrfudn638q9r",
}
}
/>
<CopyText
defaultMessage="Copy"
idMessage="integrations.copy"
value="xoxz1z7c3tgi9xhrfudn638q9r"
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__creation"
>
<MemoizedFormattedMessage
defaultMessage="Created by {creator} on {createAt, date, full}"
id="installed_integrations.creation"
values={
Object {
"createAt": 1508327769020,
"creator": "username",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__url word-break--all"
>
<MemoizedFormattedMessage
defaultMessage="Callback URLs: {urls}"
id="installed_integrations.callback_urls"
values={
Object {
"urls": "http://adsfdasd.com",
}
}
/>
</span>
</div>
</div>
</div>
`;

Просмотреть файл

@@ -0,0 +1,631 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {FormattedMessage} from 'react-intl';
import {Link} from 'react-router-dom';
import BackstageHeader from 'components/backstage/components/backstage_header';
import Constants from 'utils/constants';
import * as Utils from 'utils/utils';
import FormError from 'components/form_error';
import SpinnerButton from 'components/spinner_button';
import LocalizedInput from 'components/localized_input/localized_input';
import {t} from 'utils/i18n';
import ExternalLink from 'components/external_link';
const REQUEST_POST = 'P';
const REQUEST_GET = 'G';
export default class AbstractCommand extends React.PureComponent {
static propTypes = {
/**
* The current team
*/
team: PropTypes.object.isRequired,
/**
* The header text to render, has id and defaultMessage
*/
header: PropTypes.object.isRequired,
/**
* The footer text to render, has id and defaultMessage
*/
footer: PropTypes.object.isRequired,
/**
* The spinner loading text to render, has id and defaultMessage
*/
loading: PropTypes.object.isRequired,
/**
* Any extra component/node to render
*/
renderExtra: PropTypes.node.isRequired,
/**
* The server error text after a failed action
*/
serverError: PropTypes.string.isRequired,
/**
* The Command used to set the initial state
*/
initialCommand: PropTypes.object,
/**
* The async function to run when the action button is pressed
*/
action: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
this.state = this.getStateFromCommand(this.props.initialCommand || {});
}
getStateFromCommand = (command) => {
return {
displayName: command.display_name || '',
description: command.description || '',
trigger: command.trigger || '',
url: command.url || '',
method: command.method || REQUEST_POST,
username: command.username || '',
iconUrl: command.icon_url || '',
autocomplete: command.auto_complete || false,
autocompleteHint: command.auto_complete_hint || '',
autocompleteDescription: command.auto_complete_desc || '',
saving: false,
clientError: null,
};
}
handleSubmit = (e) => {
e.preventDefault();
if (this.state.saving) {
return;
}
this.setState({
saving: true,
clientError: '',
});
let triggerWord = this.state.trigger.trim().toLowerCase();
if (triggerWord.indexOf('/') === 0) {
triggerWord = triggerWord.substr(1);
}
const command = {
display_name: this.state.displayName,
description: this.state.description,
trigger: triggerWord,
url: this.state.url.trim(),
method: this.state.method,
username: this.state.username,
icon_url: this.state.iconUrl,
auto_complete: this.state.autocomplete,
team_id: this.props.team.id,
};
if (command.auto_complete) {
command.auto_complete_desc = this.state.autocompleteDescription;
command.auto_complete_hint = this.state.autocompleteHint;
}
if (!command.trigger) {
this.setState({
saving: false,
clientError: (
<FormattedMessage
id='add_command.triggerRequired'
defaultMessage='A trigger word is required'
/>
),
});
return;
}
if (command.trigger.indexOf('/') === 0) {
this.setState({
saving: false,
clientError: (
<FormattedMessage
id='add_command.triggerInvalidSlash'
defaultMessage='A trigger word cannot begin with a /'
/>
),
});
return;
}
if (command.trigger.indexOf(' ') !== -1) {
this.setState({
saving: false,
clientError: (
<FormattedMessage
id='add_command.triggerInvalidSpace'
defaultMessage='A trigger word must not contain spaces'
/>
),
});
return;
}
if (command.trigger.length < Constants.MIN_TRIGGER_LENGTH ||
command.trigger.length > Constants.MAX_TRIGGER_LENGTH) {
this.setState({
saving: false,
clientError: (
<FormattedMessage
id='add_command.triggerInvalidLength'
defaultMessage='A trigger word must contain between {min} and {max} characters'
values={{
min: Constants.MIN_TRIGGER_LENGTH,
max: Constants.MAX_TRIGGER_LENGTH,
}}
/>
),
});
return;
}
if (!command.url) {
this.setState({
saving: false,
clientError: (
<FormattedMessage
id='add_command.urlRequired'
defaultMessage='A request URL is required'
/>
),
});
return;
}
this.props.action(command).then(() => this.setState({saving: false}));
}
updateDisplayName = (e) => {
this.setState({
displayName: e.target.value,
});
}
updateDescription = (e) => {
this.setState({
description: e.target.value,
});
}
updateTrigger = (e) => {
this.setState({
trigger: e.target.value,
});
}
updateUrl = (e) => {
this.setState({
url: e.target.value,
});
}
updateMethod = (e) => {
this.setState({
method: e.target.value,
});
}
updateUsername = (e) => {
this.setState({
username: e.target.value,
});
}
updateIconUrl = (e) => {
this.setState({
iconUrl: e.target.value,
});
}
updateAutocomplete = (e) => {
this.setState({
autocomplete: e.target.checked,
});
}
updateAutocompleteHint = (e) => {
this.setState({
autocompleteHint: e.target.value,
});
}
updateAutocompleteDescription = (e) => {
this.setState({
autocompleteDescription: e.target.value,
});
}
render() {
let autocompleteHint = null;
let autocompleteDescription = null;
if (this.state.autocomplete) {
autocompleteHint = (
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='autocompleteHint'
>
<FormattedMessage
id='add_command.autocompleteHint'
defaultMessage='Autocomplete Hint'
/>
</label>
<div className='col-md-5 col-sm-8'>
<LocalizedInput
id='autocompleteHint'
type='text'
maxLength='1024'
className='form-control'
value={this.state.autocompleteHint}
onChange={this.updateAutocompleteHint}
placeholder={{id: t('add_command.autocompleteHint.placeholder'), defaultMessage: 'Example: [Patient Name]'}}
/>
<div className='form__help'>
<FormattedMessage
id='add_command.autocompleteHint.help'
defaultMessage='(Optional) Specify the arguments associated with your slash command. These are displayed as help on the autocomplete list.'
/>
</div>
</div>
</div>
);
autocompleteDescription = (
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='autocompleteDescription'
>
<FormattedMessage
id='add_command.autocompleteDescription'
defaultMessage='Autocomplete Description'
/>
</label>
<div className='col-md-5 col-sm-8'>
<LocalizedInput
id='description'
type='text'
maxLength='128'
className='form-control'
value={this.state.autocompleteDescription}
onChange={this.updateAutocompleteDescription}
placeholder={{id: t('add_command.autocompleteDescription.placeholder'), defaultMessage: 'Example: "Returns search results for patient records"'}}
/>
<div className='form__help'>
<FormattedMessage
id='add_command.autocompleteDescription.help'
defaultMessage='(Optional) Describe your slash command for the autocomplete list.'
/>
</div>
</div>
</div>
);
}
return (
<div className='backstage-content row'>
<BackstageHeader>
<Link to={'/' + this.props.team.name + '/integrations/commands'}>
<FormattedMessage
id='installed_command.header'
defaultMessage='Slash Commands'
/>
</Link>
<FormattedMessage
id={this.props.header.id}
defaultMessage={this.props.header.defaultMessage}
/>
</BackstageHeader>
<div className='backstage-form'>
<form
className='form-horizontal'
onSubmit={this.handleSubmit}
>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='displayName'
>
<FormattedMessage
id='add_command.displayName'
defaultMessage='Title'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='displayName'
type='text'
maxLength='64'
className='form-control'
value={this.state.displayName}
onChange={this.updateDisplayName}
/>
<div className='form__help'>
<FormattedMessage
id='add_command.displayName.help'
defaultMessage='Specify a title, of up to 64 characters, for the slash command settings page.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='description'
>
<FormattedMessage
id='add_command.description'
defaultMessage='Description'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='description'
type='text'
maxLength='128'
className='form-control'
value={this.state.description}
onChange={this.updateDescription}
/>
<div className='form__help'>
<FormattedMessage
id='add_command.description.help'
defaultMessage='Describe your slash command.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='trigger'
>
<FormattedMessage
id='add_command.trigger'
defaultMessage='Command Trigger Word'
/>
</label>
<div className='col-md-5 col-sm-8'>
<LocalizedInput
id='trigger'
type='text'
maxLength={Constants.MAX_TRIGGER_LENGTH}
className='form-control'
value={this.state.trigger}
onChange={this.updateTrigger}
placeholder={{id: t('add_command.trigger.placeholder'), defaultMessage: 'Command trigger e.g. "hello" not including the slash'}}
/>
<div className='form__help'>
<FormattedMessage
id='add_command.trigger.help'
defaultMessage='Specify a trigger word that is not a built-in command, does not contain spaces, and does not begin with the slash character.'
/>
</div>
<div className='form__help'>
<FormattedMessage
id='add_command.trigger.helpExamples'
defaultMessage='Examples: client, employee, patient, weather'
/>
</div>
<div className='form__help'>
<FormattedMessage
id='add_command.trigger.helpReserved'
defaultMessage='Reserved: {link}'
values={{
link: (
<ExternalLink
href='https://developers.mattermost.com/integrate/admin-guide/admin-slash-commands/#built-in-commands'
location='abstract_command'
>
<FormattedMessage
id='add_command.trigger.helpReservedLinkText'
defaultMessage='See built-in slash commands'
/>
</ExternalLink>
),
}}
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='url'
>
<FormattedMessage
id='add_command.url'
defaultMessage='Request URL'
/>
</label>
<div className='col-md-5 col-sm-8'>
<LocalizedInput
id='url'
type='text'
maxLength='1024'
className='form-control'
value={this.state.url}
onChange={this.updateUrl}
placeholder={{id: t('add_command.url.placeholder'), defaultMessage: 'Must start with http:// or https://'}}
/>
<div className='form__help'>
<FormattedMessage
id='add_command.url.help'
defaultMessage='Specify the callback URL to receive the HTTP POST or GET event request when the slash command is run.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='method'
>
<FormattedMessage
id='add_command.method'
defaultMessage='Request Method'
/>
</label>
<div className='col-md-5 col-sm-8'>
<select
id='method'
className='form-control'
value={this.state.method}
onChange={this.updateMethod}
>
<option value={REQUEST_POST}>
{Utils.localizeMessage('add_command.method.post', 'POST')}
</option>
<option value={REQUEST_GET}>
{Utils.localizeMessage('add_command.method.get', 'GET')}
</option>
</select>
<div className='form__help'>
<FormattedMessage
id='add_command.method.help'
defaultMessage='Specify the type of request, either POST or GET, sent to the endpoint that Mattermost hits to reach your application.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='username'
>
<FormattedMessage
id='add_command.username'
defaultMessage='Response Username'
/>
</label>
<div className='col-md-5 col-sm-8'>
<LocalizedInput
id='username'
type='text'
maxLength='64'
className='form-control'
value={this.state.username}
onChange={this.updateUsername}
placeholder={{id: t('add_command.username.placeholder'), defaultMessage: 'Username'}}
/>
<div className='form__help'>
<FormattedMessage
id='add_command.username.help'
defaultMessage='(Optional) Specify the name to use when posting responses for this slash command. Usernames can be up to 22 characters, and contain lowercase letters, numbers, and the symbols \"-\", \"_\", and \".\". If left blank, your Mattermost username is used.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='iconUrl'
>
<FormattedMessage
id='add_command.iconUrl'
defaultMessage='Response Icon'
/>
</label>
<div className='col-md-5 col-sm-8'>
<LocalizedInput
id='iconUrl'
type='text'
maxLength='1024'
className='form-control'
value={this.state.iconUrl}
onChange={this.updateIconUrl}
placeholder={{id: t('add_command.iconUrl.placeholder'), defaultMessage: 'https://www.example.com/myicon.png'}}
/>
<div className='form__help'>
<FormattedMessage
id='add_command.iconUrl.help'
defaultMessage='(Optional) Enter the URL of a .png or .jpg file to use as the icon when posting responses to this slash command. The file must be at least 128 pixels by 128 pixels. If left blank, your profile picture is used.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='autocomplete'
>
<FormattedMessage
id='add_command.autocomplete'
defaultMessage='Autocomplete'
/>
</label>
<div className='col-md-5 col-sm-8 checkbox'>
<input
id='autocomplete'
type='checkbox'
checked={this.state.autocomplete}
onChange={this.updateAutocomplete}
/>
<div className='form__help'>
<FormattedMessage
id='add_command.autocomplete.help'
defaultMessage='(Optional) Show your slash command on the autocomplete list when someone types "/" in the input box.'
/>
</div>
</div>
</div>
{autocompleteHint}
{autocompleteDescription}
<div className='backstage-form__footer'>
<FormError
type='backstage'
errors={[this.props.serverError, this.state.clientError]}
/>
<Link
className='btn btn-link btn-sm'
to={'/' + this.props.team.name + '/integrations/commands'}
>
<FormattedMessage
id='add_command.cancel'
defaultMessage='Cancel'
/>
</Link>
<SpinnerButton
className='btn btn-primary'
type='submit'
spinning={this.state.saving}
spinningText={Utils.localizeMessage(this.props.loading.id, this.props.loading.defaultMessage)}
onClick={this.handleSubmit}
id='saveCommand'
>
<FormattedMessage
id={this.props.footer.id}
defaultMessage={this.props.footer.defaultMessage}
/>
</SpinnerButton>
{this.props.renderExtra}
</div>
</form>
</div>
</div>
);
}
}

Просмотреть файл

@@ -0,0 +1,257 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {FormattedMessage} from 'react-intl';
import AbstractCommand from 'components/integrations/abstract_command.jsx';
describe('components/integrations/AbstractCommand', () => {
const header = {id: 'Header', defaultMessage: 'Header'};
const footer = {id: 'Footer', defaultMessage: 'Footer'};
const loading = {id: 'Loading', defaultMessage: 'Loading'};
const command = {
id: 'r5tpgt4iepf45jt768jz84djic',
display_name: 'display_name',
description: 'description',
trigger: 'trigger',
auto_complete: true,
auto_complete_hint: 'auto_complete_hint',
auto_complete_desc: 'auto_complete_desc',
token: 'jb6oyqh95irpbx8fo9zmndkp1r',
create_at: '1499722850203',
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
delete_at: 0,
icon_url: 'https://google.com/icon',
method: 'G',
team_id: 'm5gix3oye3du8ghk4ko6h9cq7y',
update_at: 1504468859001,
url: 'https://google.com/command',
username: 'username',
};
const team = {
name: 'test',
id: command.team_id,
};
const action = jest.fn().mockImplementation(
() => {
return new Promise((resolve) => {
process.nextTick(() => resolve());
});
},
);
const baseProps = {
team,
header,
footer,
loading,
renderExtra: 'renderExtra',
serverError: '',
initialCommand: command,
action,
};
test('should match snapshot', () => {
const wrapper = shallow(
<AbstractCommand {...baseProps}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, displays client error', () => {
const newSeverError = 'server error';
const props = {...baseProps, serverError: newSeverError};
const wrapper = shallow(
<AbstractCommand {...props}/>,
);
wrapper.find('#trigger').simulate('change', {target: {value: ''}});
wrapper.find('.btn-primary').simulate('click', {preventDefault: jest.fn()});
expect(wrapper).toMatchSnapshot();
expect(action).not.toBeCalled();
});
test('should call action function', () => {
const wrapper = shallow(
<AbstractCommand {...baseProps}/>,
);
wrapper.find('#displayName').simulate('change', {target: {value: 'name'}});
wrapper.find('.btn-primary').simulate('click', {preventDefault: jest.fn()});
expect(action).toBeCalled();
});
test('should match object returned by getStateFromCommand', () => {
const wrapper = shallow(
<AbstractCommand {...baseProps}/>,
);
const expectedOutput = {
autocomplete: true,
autocompleteDescription: 'auto_complete_desc',
autocompleteHint: 'auto_complete_hint',
clientError: null,
description: 'description',
displayName: 'display_name',
iconUrl: 'https://google.com/icon',
method: 'G',
saving: false,
trigger: 'trigger',
url: 'https://google.com/command',
username: 'username',
};
expect(wrapper.instance().getStateFromCommand(command)).toEqual(expectedOutput);
});
test('should match state when method is called', () => {
const wrapper = shallow(
<AbstractCommand {...baseProps}/>,
);
const displayName = 'new display_name';
wrapper.instance().updateDisplayName({target: {value: displayName}});
expect(wrapper.state('displayName')).toEqual(displayName);
const description = 'new description';
wrapper.instance().updateDescription({target: {value: description}});
expect(wrapper.state('description')).toEqual(description);
const trigger = 'new trigger';
wrapper.instance().updateTrigger({target: {value: trigger}});
expect(wrapper.state('trigger')).toEqual(trigger);
const url = 'new url';
wrapper.instance().updateUrl({target: {value: url}});
expect(wrapper.state('url')).toEqual(url);
const method = 'new method';
wrapper.instance().updateMethod({target: {value: method}});
expect(wrapper.state('method')).toEqual(method);
const username = 'new username';
wrapper.instance().updateUsername({target: {value: username}});
expect(wrapper.state('username')).toEqual(username);
const iconUrl = 'new iconUrl';
wrapper.instance().updateIconUrl({target: {value: iconUrl}});
expect(wrapper.state('iconUrl')).toEqual(iconUrl);
wrapper.instance().updateAutocomplete({target: {checked: true}});
expect(wrapper.state('autocomplete')).toEqual(true);
wrapper.instance().updateAutocomplete({target: {checked: false}});
expect(wrapper.state('autocomplete')).toEqual(false);
const autocompleteHint = 'new autocompleteHint';
wrapper.instance().updateAutocompleteHint({target: {value: autocompleteHint}});
expect(wrapper.state('autocompleteHint')).toEqual(autocompleteHint);
const autocompleteDescription = 'new autocompleteDescription';
wrapper.instance().updateAutocompleteDescription({target: {value: autocompleteDescription}});
expect(wrapper.state('autocompleteDescription')).toEqual(autocompleteDescription);
});
test('should match state when handleSubmit is called', () => {
const newAction = jest.fn().mockImplementation(
() => {
return new Promise((resolve) => {
process.nextTick(() => resolve());
});
},
);
const props = {...baseProps, action: newAction};
const wrapper = shallow(
<AbstractCommand {...props}/>,
);
expect(newAction).toHaveBeenCalledTimes(0);
const evt = {preventDefault: jest.fn()};
const handleSubmit = wrapper.instance().handleSubmit;
handleSubmit(evt);
expect(wrapper.state('saving')).toEqual(true);
expect(wrapper.state('clientError')).toEqual('');
expect(newAction).toHaveBeenCalledTimes(1);
// saving is true
wrapper.setState({saving: true});
handleSubmit(evt);
expect(wrapper.state('clientError')).toEqual('');
expect(newAction).toHaveBeenCalledTimes(1);
// empty trigger
wrapper.setState({saving: false, trigger: ''});
handleSubmit(evt);
expect(wrapper.state('clientError')).toEqual(
<FormattedMessage
defaultMessage='A trigger word is required'
id='add_command.triggerRequired'
/>,
);
expect(newAction).toHaveBeenCalledTimes(1);
// trigger that starts with a slash '/'
wrapper.setState({saving: false, trigger: '//startwithslash'});
handleSubmit(evt);
expect(wrapper.state('clientError')).toEqual(
<FormattedMessage
defaultMessage='A trigger word cannot begin with a /'
id='add_command.triggerInvalidSlash'
/>,
);
expect(newAction).toHaveBeenCalledTimes(1);
// trigger with space
wrapper.setState({saving: false, trigger: '/trigger with space'});
handleSubmit(evt);
expect(wrapper.state('clientError')).toEqual(
<FormattedMessage
defaultMessage='A trigger word must not contain spaces'
id='add_command.triggerInvalidSpace'
/>,
);
expect(newAction).toHaveBeenCalledTimes(1);
// trigger above maximum length
wrapper.setState({saving: false, trigger: '123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789'});
handleSubmit(evt);
expect(wrapper.state('clientError')).toEqual(
<FormattedMessage
defaultMessage='A trigger word must contain between {min} and {max} characters'
id='add_command.triggerInvalidLength'
values={{max: 128, min: 1}}
/>,
);
expect(newAction).toHaveBeenCalledTimes(1);
// good triggers
wrapper.setState({saving: false, trigger: '12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678'});
handleSubmit(evt);
expect(wrapper.state('clientError')).toEqual('');
expect(newAction).toHaveBeenCalledTimes(2);
wrapper.setState({saving: false, trigger: '/trigger'});
handleSubmit(evt);
expect(wrapper.state('clientError')).toEqual('');
expect(newAction).toHaveBeenCalledTimes(3);
wrapper.setState({saving: false, trigger: 'trigger'});
handleSubmit(evt);
expect(wrapper.state('clientError')).toEqual('');
expect(newAction).toHaveBeenCalledTimes(4);
// empty url
wrapper.setState({saving: false, trigger: 'trigger', url: ''});
handleSubmit(evt);
expect(wrapper.state('clientError')).toEqual(
<FormattedMessage
defaultMessage='A request URL is required'
id='add_command.urlRequired'
/>,
);
expect(newAction).toHaveBeenCalledTimes(4);
});
});

Просмотреть файл

@@ -0,0 +1,150 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import AbstractIncomingWebhook from 'components/integrations/abstract_incoming_webhook';
describe('components/integrations/AbstractIncomingWebhook', () => {
const team = {name: 'team_name'};
const header = {id: 'header_id', defaultMessage: 'Header'};
const footer = {id: 'footer_id', defaultMessage: 'Footer'};
const loading = {id: 'loading_id', defaultMessage: 'Loading'};
const serverError = '';
const initialHook = {
display_name: 'testIncomingWebhook',
channel_id: '88cxd9wpzpbpfp8pad78xj75pr',
description: 'testing',
};
const enablePostUsernameOverride = true;
const enablePostIconOverride = true;
const action = jest.fn().mockImplementation(
() => {
return new Promise<void>((resolve) => {
process.nextTick(() => resolve());
});
},
);
const requiredProps = {
team,
header,
footer,
loading,
serverError,
initialHook,
enablePostUsernameOverride,
enablePostIconOverride,
action,
};
test('should match snapshot', () => {
const wrapper = shallow(<AbstractIncomingWebhook {...requiredProps}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, on serverError', () => {
const newServerError = 'serverError';
const props = {...requiredProps, serverError: newServerError};
const wrapper = shallow(<AbstractIncomingWebhook {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, displays client error when no initial hook', () => {
const newInitialHook = {};
const props = {...requiredProps, initialHook: newInitialHook};
const wrapper = shallow(<AbstractIncomingWebhook {...props}/>);
wrapper.find('.btn-primary').simulate('click', {preventDefault() {
return jest.fn();
}});
expect(action).not.toBeCalled();
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, hiding post username if not enabled', () => {
const props = {
...requiredProps,
enablePostUsernameOverride: false,
};
const wrapper = shallow(<AbstractIncomingWebhook {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, hiding post icon url if not enabled', () => {
const props = {
...requiredProps,
enablePostIconOverride: false,
};
const wrapper = shallow(<AbstractIncomingWebhook {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should call action function', () => {
const wrapper = shallow(<AbstractIncomingWebhook {...requiredProps}/>);
expect(wrapper).toMatchSnapshot();
wrapper.find('#displayName').simulate('change', {target: {value: 'name'}});
wrapper.find('.btn-primary').simulate('click', {preventDefault() {
return jest.fn();
}});
expect(action).toBeCalled();
expect(action).toHaveBeenCalledTimes(1);
});
test('should update state.channelId when on channel change', () => {
const newChannelId = 'new_channel_id';
const evt = {
preventDefault: jest.fn(),
target: {value: newChannelId},
};
const wrapper = shallow(<AbstractIncomingWebhook {...requiredProps}/>);
wrapper.find('#channelId').simulate('change', evt);
expect(wrapper.state('channelId')).toBe(newChannelId);
});
test('should update state.description when on description change', () => {
const newDescription = 'new_description';
const evt = {
preventDefault: jest.fn(),
target: {value: newDescription},
};
const wrapper = shallow(<AbstractIncomingWebhook {...requiredProps}/>);
wrapper.find('#description').simulate('change', evt);
expect(wrapper.state('description')).toBe(newDescription);
});
test('should update state.username on post username change', () => {
const newUsername = 'new_username';
const evt = {
preventDefault: jest.fn(),
target: {value: newUsername},
};
const wrapper = shallow(<AbstractIncomingWebhook {...requiredProps}/>);
wrapper.find('#username').simulate('change', evt);
expect(wrapper.state('username')).toBe(newUsername);
});
test('should update state.iconURL on post icon url change', () => {
const newIconURL = 'http://example.com/icon';
const evt = {
preventDefault: jest.fn(),
target: {value: newIconURL},
};
const wrapper = shallow(<AbstractIncomingWebhook {...requiredProps}/>);
wrapper.find('#iconURL').simulate('change', evt);
expect(wrapper.state('iconURL')).toBe(newIconURL);
});
});

Просмотреть файл

@@ -0,0 +1,378 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import PropTypes from 'prop-types';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {Link} from 'react-router-dom';
import BackstageHeader from 'components/backstage/components/backstage_header';
import ChannelSelect from 'components/channel_select';
import FormError from 'components/form_error';
import SpinnerButton from 'components/spinner_button';
import {localizeMessage} from 'utils/utils';
export default class AbstractIncomingWebhook extends React.PureComponent {
static propTypes = {
/**
* The current team
*/
team: PropTypes.object.isRequired,
/**
* The header text to render, has id and defaultMessage
*/
header: PropTypes.object.isRequired,
/**
* The footer text to render, has id and defaultMessage
*/
footer: PropTypes.object.isRequired,
/**
* The spinner loading text to render, has id and defaultMessage
*/
loading: PropTypes.object.isRequired,
/**
* The server error text after a failed action
*/
serverError: PropTypes.string.isRequired,
/**
* The hook used to set the initial state
*/
initialHook: PropTypes.object,
/**
* Whether to allow configuration of the default post username.
*/
enablePostUsernameOverride: PropTypes.bool.isRequired,
/**
* Whether to allow configuration of the default post icon.
*/
enablePostIconOverride: PropTypes.bool.isRequired,
/**
* The async function to run when the action button is pressed
*/
action: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
this.state = this.getStateFromHook(this.props.initialHook || {});
}
getStateFromHook = (hook) => {
return {
displayName: hook.display_name || '',
description: hook.description || '',
channelId: hook.channel_id || '',
channelLocked: hook.channel_locked || false,
username: hook.username || '',
iconURL: hook.icon_url || '',
saving: false,
serverError: '',
clientError: null,
};
}
handleSubmit = (e) => {
e.preventDefault();
if (this.state.saving) {
return;
}
this.setState({
saving: true,
serverError: '',
clientError: '',
});
if (!this.state.channelId) {
this.setState({
saving: false,
clientError: (
<FormattedMessage
id='add_incoming_webhook.channelRequired'
defaultMessage='A valid channel is required'
/>
),
});
return;
}
const hook = {
channel_id: this.state.channelId,
channel_locked: this.state.channelLocked,
display_name: this.state.displayName,
description: this.state.description,
username: this.state.username,
icon_url: this.state.iconURL,
};
this.props.action(hook).then(() => this.setState({saving: false}));
}
updateDisplayName = (e) => {
this.setState({
displayName: e.target.value,
});
}
updateDescription = (e) => {
this.setState({
description: e.target.value,
});
}
updateChannelId = (e) => {
this.setState({
channelId: e.target.value,
});
}
updateChannelLocked = (e) => {
this.setState({
channelLocked: e.target.checked,
});
}
updateUsername = (e) => {
this.setState({
username: e.target.value,
});
}
updateIconURL = (e) => {
this.setState({
iconURL: e.target.value,
});
}
render() {
var headerToRender = this.props.header;
var footerToRender = this.props.footer;
return (
<div className='backstage-content'>
<BackstageHeader>
<Link to={`/${this.props.team.name}/integrations/incoming_webhooks`}>
<FormattedMessage
id='installed_incoming_webhooks.header'
defaultMessage='Incoming Webhooks'
/>
</Link>
<FormattedMessage
id={headerToRender.id}
defaultMessage={headerToRender.defaultMessage}
/>
</BackstageHeader>
<div className='backstage-form'>
<form
className='form-horizontal'
onSubmit={this.handleSubmit}
>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='displayName'
>
<FormattedMessage
id='add_incoming_webhook.displayName'
defaultMessage='Title'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='displayName'
type='text'
maxLength='64'
className='form-control'
value={this.state.displayName}
onChange={this.updateDisplayName}
/>
<div className='form__help'>
<FormattedMessage
id='add_incoming_webhook.displayName.help'
defaultMessage='Specify a title, of up to 64 characters, for the webhook settings page.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='description'
>
<FormattedMessage
id='add_incoming_webhook.description'
defaultMessage='Description'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='description'
type='text'
maxLength='500'
className='form-control'
value={this.state.description}
onChange={this.updateDescription}
/>
<div className='form__help'>
<FormattedMessage
id='add_incoming_webhook.description.help'
defaultMessage='Describe your incoming webhook.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='channelId'
>
<FormattedMessage
id='add_incoming_webhook.channel'
defaultMessage='Channel'
/>
</label>
<div className='col-md-5 col-sm-8'>
<ChannelSelect
id='channelId'
value={this.state.channelId}
onChange={this.updateChannelId}
selectOpen={true}
selectPrivate={true}
/>
<div className='form__help'>
<FormattedMessage
id='add_incoming_webhook.channel.help'
defaultMessage='This is the default public or private channel that receives the webhook payloads. When setting up the webhook, you must belong to the private channel.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='channelLocked'
>
<FormattedMessage
id='add_incoming_webhook.channelLocked'
defaultMessage='Lock to this channel'
/>
</label>
<div className='col-md-5 col-sm-8 checkbox'>
<input
id='channelLocked'
type='checkbox'
checked={this.state.channelLocked}
onChange={this.updateChannelLocked}
/>
<div className='form__help'>
<FormattedMessage
id='add_incoming_webhook.channelLocked.help'
defaultMessage='If set, the incoming webhook can post only to the selected channel.'
/>
</div>
</div>
</div>
{ this.props.enablePostUsernameOverride &&
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='username'
>
<FormattedMessage
id='add_incoming_webhook.username'
defaultMessage='Username'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='username'
type='text'
maxLength='22'
className='form-control'
value={this.state.username}
onChange={this.updateUsername}
/>
<div className='form__help'>
<FormattedMessage
id='add_incoming_webhook.username.help'
defaultMessage='Specify the username this integration will post as. Usernames can be up to 22 characters, and can contain lowercase letters, numbers and the symbols \"-\", \"_\", and \".\". If left blank, the name specified by the webhook creator is used.'
/>
</div>
</div>
</div>
}
{ this.props.enablePostIconOverride &&
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='iconURL'
>
<FormattedMessage
id='add_incoming_webhook.icon_url'
defaultMessage='Profile Picture'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='iconURL'
type='text'
maxLength='1024'
className='form-control'
value={this.state.iconURL}
onChange={this.updateIconURL}
/>
<div className='form__help'>
<FormattedMessage
id='add_incoming_webhook.icon_url.help'
defaultMessage='Enter the URL of a .png or .jpg file for the profile picture of this integration when posting. The file should be at least 128 pixels by 128 pixels. If left blank, the profile picture specified by the webhook creator is used.'
/>
</div>
</div>
</div>
}
<div className='backstage-form__footer'>
<FormError
type='backstage'
errors={[this.props.serverError, this.state.clientError]}
/>
<Link
className='btn btn-link btn-sm'
to={`/${this.props.team.name}/integrations/incoming_webhooks`}
>
<FormattedMessage
id='add_incoming_webhook.cancel'
defaultMessage='Cancel'
/>
</Link>
<SpinnerButton
className='btn btn-primary'
type='submit'
spinning={this.state.saving}
spinningText={localizeMessage(this.props.loading.id, this.props.loading.defaultMessage)}
onClick={this.handleSubmit}
id='saveWebhook'
>
<FormattedMessage
id={footerToRender.id}
defaultMessage={footerToRender.defaultMessage}
/>
</SpinnerButton>
</div>
</form>
</div>
</div>
);
}
}

Просмотреть файл

@@ -0,0 +1,474 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {FormattedMessage} from 'react-intl';
import {Link} from 'react-router-dom';
import {Permissions} from 'mattermost-redux/constants';
import {localizeMessage} from 'utils/utils';
import BackstageHeader from 'components/backstage/components/backstage_header';
import FormError from 'components/form_error';
import SpinnerButton from 'components/spinner_button';
import SystemPermissionGate from 'components/permissions_gates/system_permission_gate';
export default class AbstractOAuthApp extends React.PureComponent {
static propTypes = {
/**
* The current team
*/
team: PropTypes.object.isRequired,
/**
* The header text to render, has id and defaultMessage
*/
header: PropTypes.object.isRequired,
/**
* The footer text to render, has id and defaultMessage
*/
footer: PropTypes.object.isRequired,
/**
* The spinner loading text to render, has id and defaultMessage
*/
loading: PropTypes.object.isRequired,
/**
* Any extra component/node to render
*/
renderExtra: PropTypes.node.isRequired,
/**
* The server error text after a failed action
*/
serverError: PropTypes.string.isRequired,
/**
* The OAuthApp used to set the initial state
*/
initialApp: PropTypes.object,
/**
* The async function to run when the action button is pressed
*/
action: PropTypes.func.isRequired,
}
constructor(props) {
super(props);
this.image = new Image();
this.image.onload = this.imageLoaded;
this.icon_url = React.createRef();
this.state = this.getStateFromApp(this.props.initialApp || {});
}
getStateFromApp = (app) => {
return {
name: app.name || '',
description: app.description || '',
homepage: app.homepage || '',
icon_url: app.icon_url || '',
callbackUrls: app.callback_urls ? app.callback_urls.join('\n') : '',
is_trusted: app.is_trusted || false,
has_icon: Boolean(app.icon_url),
saving: false,
clientError: null,
};
}
imageLoaded = () => {
this.setState({
has_icon: true,
icon_url: this.icon_url.current.value,
});
}
handleSubmit = (e) => {
e.preventDefault();
if (this.state.saving) {
return;
}
this.setState({
saving: true,
clientError: '',
});
if (!this.state.name) {
this.setState({
saving: false,
clientError: (
<FormattedMessage
id='add_oauth_app.nameRequired'
defaultMessage='Name for the OAuth 2.0 application is required.'
/>
),
});
return;
}
if (!this.state.description) {
this.setState({
saving: false,
clientError: (
<FormattedMessage
id='add_oauth_app.descriptionRequired'
defaultMessage='Description for the OAuth 2.0 application is required.'
/>
),
});
return;
}
if (!this.state.homepage) {
this.setState({
saving: false,
clientError: (
<FormattedMessage
id='add_oauth_app.homepageRequired'
defaultMessage='Homepage for the OAuth 2.0 application is required.'
/>
),
});
return;
}
const callbackUrls = [];
for (let callbackUrl of this.state.callbackUrls.split('\n')) {
callbackUrl = callbackUrl.trim();
if (callbackUrl.length > 0) {
callbackUrls.push(callbackUrl);
}
}
if (callbackUrls.length === 0) {
this.setState({
saving: false,
clientError: (
<FormattedMessage
id='add_oauth_app.callbackUrlsRequired'
defaultMessage='One or more callback URLs are required.'
/>
),
});
return;
}
const app = {
name: this.state.name,
callback_urls: callbackUrls,
homepage: this.state.homepage,
description: this.state.description,
is_trusted: this.state.is_trusted,
icon_url: this.state.icon_url,
};
this.props.action(app).then(() => this.setState({saving: false}));
}
updateName = (e) => {
this.setState({
name: e.target.value,
});
}
updateTrusted = (e) => {
this.setState({
is_trusted: e.target.value === 'true',
});
}
updateDescription = (e) => {
this.setState({
description: e.target.value,
});
}
updateHomepage = (e) => {
this.setState({
homepage: e.target.value,
});
}
updateIconUrl = (e) => {
this.setState({
has_icon: false,
icon_url: e.target.value,
});
this.image.src = e.target.value;
}
updateCallbackUrls = (e) => {
this.setState({
callbackUrls: e.target.value,
});
}
render() {
const headerToRender = this.props.header;
const footerToRender = this.props.footer;
const renderExtra = this.props.renderExtra;
let icon;
if (this.state.has_icon) {
icon = (
<div className='integration__icon'>
<img
alt={'integration icon'}
src={this.state.icon_url}
/>
</div>
);
}
const trusted = (
<SystemPermissionGate permissions={[Permissions.MANAGE_SYSTEM]}>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='is_trusted'
>
<FormattedMessage
id='installed_oauth_apps.trusted'
defaultMessage='Is Trusted'
/>
</label>
<div className='col-md-5 col-sm-8'>
<label className='radio-inline'>
<input
type='radio'
value='true'
name='is_trusted'
checked={this.state.is_trusted}
onChange={this.updateTrusted}
/>
<FormattedMessage
id='installed_oauth_apps.trusted.yes'
defaultMessage='Yes'
/>
</label>
<label className='radio-inline'>
<input
type='radio'
value='false'
name='is_trusted'
checked={!this.state.is_trusted}
onChange={this.updateTrusted}
/>
<FormattedMessage
id='installed_oauth_apps.trusted.no'
defaultMessage='No'
/>
</label>
<div className='form__help'>
<FormattedMessage
id='add_oauth_app.trusted.help'
defaultMessage='If true, the OAuth 2.0 application is considered trusted by the Mattermost server and does not require the user to accept authorization. If false, a window opens to ask the user to accept or deny the authorization.'
/>
</div>
</div>
</div>
</SystemPermissionGate>
);
return (
<div className='backstage-content'>
<BackstageHeader>
<Link to={`/${this.props.team.name}/integrations/oauth2-apps`}>
<FormattedMessage
id='installed_oauth_apps.header'
defaultMessage='Installed OAuth2 Apps'
/>
</Link>
<FormattedMessage
id={headerToRender.id}
defaultMessage={headerToRender.defaultMessage}
/>
</BackstageHeader>
<div className='backstage-form'>
{icon}
<form className='form-horizontal'>
{trusted}
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='name'
>
<FormattedMessage
id='installed_oauth_apps.name'
defaultMessage='Display Name'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='name'
type='text'
maxLength='64'
className='form-control'
value={this.state.name}
onChange={this.updateName}
/>
<div className='form__help'>
<FormattedMessage
id='add_oauth_app.name.help'
defaultMessage='Specify the display name, of up to 64 characters, for your OAuth 2.0 application.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='description'
>
<FormattedMessage
id='installed_oauth_apps.description'
defaultMessage='Description'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='description'
type='text'
maxLength='512'
className='form-control'
value={this.state.description}
onChange={this.updateDescription}
/>
<div className='form__help'>
<FormattedMessage
id='add_oauth_app.description.help'
defaultMessage='Describe your OAuth 2.0 application.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='homepage'
>
<FormattedMessage
id='installed_oauth_apps.homepage'
defaultMessage='Homepage'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='homepage'
type='url'
maxLength='256'
className='form-control'
value={this.state.homepage}
onChange={this.updateHomepage}
/>
<div className='form__help'>
<FormattedMessage
id='add_oauth_app.homepage.help'
defaultMessage='This is the URL for the homepage of the OAuth 2.0 application. Depending on your server configuration, use HTTP or HTTPS in the URL.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='icon_url'
>
<FormattedMessage
id='installed_oauth_apps.iconUrl'
defaultMessage='Icon URL'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='icon_url'
ref={this.icon_url}
type='url'
maxLength='512'
className='form-control'
value={this.state.icon_url}
onChange={this.updateIconUrl}
/>
<div className='form__help'>
<FormattedMessage
id='add_oauth_app.icon.help'
defaultMessage='(Optional) The URL of the image used for your OAuth 2.0 application. Make sure you use HTTP or HTTPS in your URL.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='callbackUrls'
>
<FormattedMessage
id='installed_oauth_apps.callbackUrls'
defaultMessage='Callback URLs (One Per Line)'
/>
</label>
<div className='col-md-5 col-sm-8'>
<textarea
id='callbackUrls'
rows='3'
maxLength='1024'
className='form-control'
value={this.state.callbackUrls}
onChange={this.updateCallbackUrls}
/>
<div className='form__help'>
<FormattedMessage
id='add_oauth_app.callbackUrls.help'
defaultMessage='The redirect URIs to which the service will redirect users after accepting or denying authorization of your application, and which will handle authorization codes or access tokens. Must be a valid URL and start with http:// or https://.'
/>
</div>
</div>
</div>
<div className='backstage-form__footer'>
<FormError
type='backstage'
errors={[this.props.serverError, this.state.clientError]}
/>
<Link
className='btn btn-link btn-sm'
to={`/${this.props.team.name}/integrations/oauth2-apps`}
>
<FormattedMessage
id='installed_oauth_apps.cancel'
defaultMessage='Cancel'
/>
</Link>
<SpinnerButton
className='btn btn-primary'
type='submit'
spinning={this.state.saving}
spinningText={localizeMessage(this.props.loading.id, this.props.loading.defaultMessage)}
onClick={this.handleSubmit}
id='saveOauthApp'
>
<FormattedMessage
id={footerToRender.id}
defaultMessage={footerToRender.defaultMessage}
/>
</SpinnerButton>
{renderExtra}
</div>
</form>
</div>
</div>
);
}
}

Просмотреть файл

@@ -0,0 +1,199 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {shallow} from 'enzyme';
import AbstractOAuthApp from 'components/integrations/abstract_oauth_app.jsx';
describe('components/integrations/AbstractOAuthApp', () => {
const team = {name: 'test'};
const header = {id: 'Header', defaultMessage: 'Header'};
const footer = {id: 'Footer', defaultMessage: 'Footer'};
const loading = {id: 'Loading', defaultMessage: 'Loading'};
const initialApp = {
id: 'facxd9wpzpbpfp8pad78xj75pr',
name: 'testApp',
client_secret: '88cxd9wpzpbpfp8pad78xj75pr',
create_at: 1501365458934,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
description: 'testing',
homepage: 'https://test.com',
icon_url: 'https://test.com/icon',
is_trusted: true,
update_at: 1501365458934,
callback_urls: ['https://test.com/callback', 'https://test.com/callback2'],
};
const action = jest.fn().mockImplementation(
() => {
return new Promise((resolve) => {
process.nextTick(() => resolve());
});
},
);
const baseProps = {
team,
header,
footer,
loading,
renderExtra: 'renderExtra',
serverError: '',
initialApp,
action: jest.fn(),
};
test('should match snapshot', () => {
const wrapper = shallow(
<AbstractOAuthApp {...baseProps}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, displays client error', () => {
const newServerError = 'serverError';
const props = {...baseProps, serverError: newServerError};
const wrapper = shallow(
<AbstractOAuthApp {...props}/>,
);
wrapper.find('#callbackUrls').simulate('change', {target: {value: ''}});
wrapper.find('.btn-primary').simulate('click', {preventDefault() {
return jest.fn();
}});
expect(action).not.toBeCalled();
expect(wrapper).toMatchSnapshot();
});
test('should call action function', () => {
const props = {...baseProps, action};
const wrapper = shallow(
<AbstractOAuthApp {...props}/>,
);
wrapper.find('#name').simulate('change', {target: {value: 'name'}});
wrapper.find('.btn-primary').simulate('click', {preventDefault() {
return jest.fn();
}});
expect(action).toBeCalled();
});
test('should have correct state when updateName is called', () => {
const props = {...baseProps, action};
const wrapper = shallow(
<AbstractOAuthApp {...props}/>,
);
wrapper.instance().updateName({target: {value: 'new name'}});
expect(wrapper.state('name')).toEqual('new name');
wrapper.instance().updateName({target: {value: 'other name'}});
expect(wrapper.state('name')).toEqual('other name');
});
test('should have correct state when updateTrusted is called', () => {
const props = {...baseProps, action};
const wrapper = shallow(
<AbstractOAuthApp {...props}/>,
);
wrapper.instance().updateTrusted({target: {value: 'false'}});
expect(wrapper.state('is_trusted')).toEqual(false);
wrapper.instance().updateTrusted({target: {value: 'true'}});
expect(wrapper.state('is_trusted')).toEqual(true);
});
test('should have correct state when updateDescription is called', () => {
const props = {...baseProps, action};
const wrapper = shallow(
<AbstractOAuthApp {...props}/>,
);
wrapper.instance().updateDescription({target: {value: 'new description'}});
expect(wrapper.state('description')).toEqual('new description');
wrapper.instance().updateDescription({target: {value: 'another description'}});
expect(wrapper.state('description')).toEqual('another description');
});
test('should have correct state when updateHomepage is called', () => {
const props = {...baseProps, action};
const wrapper = shallow(
<AbstractOAuthApp {...props}/>,
);
wrapper.instance().updateHomepage({target: {value: 'new homepage'}});
expect(wrapper.state('homepage')).toEqual('new homepage');
wrapper.instance().updateHomepage({target: {value: 'another homepage'}});
expect(wrapper.state('homepage')).toEqual('another homepage');
});
test('should have correct state when updateIconUrl is called', () => {
const props = {...baseProps, action};
const wrapper = shallow(
<AbstractOAuthApp {...props}/>,
);
wrapper.setState({has_icon: true});
wrapper.instance().updateIconUrl({target: {value: 'https://test.com/new_icon_url'}});
expect(wrapper.state('icon_url')).toEqual('https://test.com/new_icon_url');
expect(wrapper.state('has_icon')).toEqual(false);
wrapper.setState({has_icon: true});
wrapper.instance().updateIconUrl({target: {value: 'https://test.com/another_icon_url'}});
expect(wrapper.state('icon_url')).toEqual('https://test.com/another_icon_url');
expect(wrapper.state('has_icon')).toEqual(false);
});
test('should have correct state when handleSubmit is called', () => {
const props = {...baseProps, action};
const wrapper = shallow(
<AbstractOAuthApp {...props}/>,
);
const newState = {saving: false, name: 'name', description: 'description', homepage: 'homepage'};
const evt = {preventDefault: jest.fn()};
wrapper.setState({saving: true});
wrapper.instance().handleSubmit(evt);
expect(evt.preventDefault).toHaveBeenCalled();
wrapper.setState(newState);
wrapper.instance().handleSubmit(evt);
expect(wrapper.state('saving')).toEqual(true);
expect(wrapper.state('clientError')).toEqual('');
wrapper.setState({...newState, name: ''});
wrapper.instance().handleSubmit(evt);
expect(wrapper.state('saving')).toEqual(false);
expect(wrapper.state('clientError')).toEqual(
<FormattedMessage
defaultMessage='Name for the OAuth 2.0 application is required.'
id='add_oauth_app.nameRequired'
/>,
);
wrapper.setState({...newState, description: ''});
wrapper.instance().handleSubmit(evt);
expect(wrapper.state('saving')).toEqual(false);
expect(wrapper.state('clientError')).toEqual(
<FormattedMessage
defaultMessage='Description for the OAuth 2.0 application is required.'
id='add_oauth_app.descriptionRequired'
/>,
);
wrapper.setState({...newState, homepage: ''});
wrapper.instance().handleSubmit(evt);
expect(wrapper.state('saving')).toEqual(false);
expect(wrapper.state('clientError')).toEqual(
<FormattedMessage
defaultMessage='Homepage for the OAuth 2.0 application is required.'
id='add_oauth_app.homepageRequired'
/>,
);
});
});

Просмотреть файл

@@ -0,0 +1,586 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import PropTypes from 'prop-types';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {Link} from 'react-router-dom';
import {localizeMessage} from 'utils/utils';
import BackstageHeader from 'components/backstage/components/backstage_header';
import ChannelSelect from 'components/channel_select';
import FormError from 'components/form_error';
import SpinnerButton from 'components/spinner_button';
import ExternalLink from 'components/external_link';
export default class AbstractOutgoingWebhook extends React.PureComponent {
static propTypes = {
/**
* The current team
*/
team: PropTypes.object.isRequired,
/**
* The header text to render, has id and defaultMessage
*/
header: PropTypes.object.isRequired,
/**
* The footer text to render, has id and defaultMessage
*/
footer: PropTypes.object.isRequired,
/**
* The spinner loading text to render, has id and defaultMessage
*/
loading: PropTypes.object.isRequired,
/**
* Any extra component/node to render
*/
renderExtra: PropTypes.node.isRequired,
/**
* The server error text after a failed action
*/
serverError: PropTypes.string.isRequired,
/**
* The hook used to set the initial state
*/
initialHook: PropTypes.object,
/**
* The async function to run when the action button is pressed
*/
action: PropTypes.func.isRequired,
/**
* Whether to allow configuration of the default post username.
*/
enablePostUsernameOverride: PropTypes.bool.isRequired,
/**
* Whether to allow configuration of the default post icon.
*/
enablePostIconOverride: PropTypes.bool.isRequired,
}
constructor(props) {
super(props);
this.state = this.getStateFromHook(this.props.initialHook || {});
}
getStateFromHook = (hook) => {
let triggerWords = '';
if (hook.trigger_words) {
let i = 0;
for (i = 0; i < hook.trigger_words.length; i++) {
triggerWords += hook.trigger_words[i] + '\n';
}
}
let callbackUrls = '';
if (hook.callback_urls) {
let i = 0;
for (i = 0; i < hook.callback_urls.length; i++) {
callbackUrls += hook.callback_urls[i] + '\n';
}
}
return {
displayName: hook.display_name || '',
description: hook.description || '',
contentType: hook.content_type || 'application/x-www-form-urlencoded',
channelId: hook.channel_id || '',
triggerWords,
triggerWhen: hook.trigger_when || 0,
callbackUrls,
saving: false,
clientError: null,
username: hook.username || '',
iconURL: hook.icon_url || '',
};
}
handleSubmit = (e) => {
e.preventDefault();
if (this.state.saving) {
return;
}
this.setState({
saving: true,
clientError: '',
});
const triggerWords = [];
if (this.state.triggerWords) {
for (let triggerWord of this.state.triggerWords.split('\n')) {
triggerWord = triggerWord.trim();
if (triggerWord.length > 0) {
triggerWords.push(triggerWord);
}
}
}
if (!this.state.channelId && triggerWords.length === 0) {
this.setState({
saving: false,
clientError: (
<FormattedMessage
id='add_outgoing_webhook.triggerWordsOrChannelRequired'
defaultMessage='A valid channel or a list of trigger words is required'
/>
),
});
return;
}
const callbackUrls = [];
for (let callbackUrl of this.state.callbackUrls.split('\n')) {
callbackUrl = callbackUrl.trim();
if (callbackUrl.length > 0) {
callbackUrls.push(callbackUrl);
}
}
if (callbackUrls.length === 0) {
this.setState({
saving: false,
clientError: (
<FormattedMessage
id='add_outgoing_webhook.callbackUrlsRequired'
defaultMessage='One or more callback URLs are required'
/>
),
});
return;
}
const hook = {
team_id: this.props.team.id,
channel_id: this.state.channelId,
trigger_words: triggerWords,
trigger_when: parseInt(this.state.triggerWhen, 10),
callback_urls: callbackUrls,
display_name: this.state.displayName,
content_type: this.state.contentType,
description: this.state.description,
username: this.state.username,
icon_url: this.state.iconURL,
};
this.props.action(hook).then(() => this.setState({saving: false}));
}
updateDisplayName = (e) => {
this.setState({
displayName: e.target.value,
});
}
updateDescription = (e) => {
this.setState({
description: e.target.value,
});
}
updateContentType = (e) => {
this.setState({
contentType: e.target.value,
});
}
updateChannelId = (e) => {
this.setState({
channelId: e.target.value,
});
}
updateTriggerWords = (e) => {
this.setState({
triggerWords: e.target.value,
});
}
updateTriggerWhen = (e) => {
this.setState({
triggerWhen: e.target.value,
});
}
updateCallbackUrls = (e) => {
this.setState({
callbackUrls: e.target.value,
});
}
updateUsername = (e) => {
this.setState({
username: e.target.value,
});
}
updateIconURL = (e) => {
this.setState({
iconURL: e.target.value,
});
}
render() {
const contentTypeOption1 = 'application/x-www-form-urlencoded';
const contentTypeOption2 = 'application/json';
var headerToRender = this.props.header;
var footerToRender = this.props.footer;
var renderExtra = this.props.renderExtra;
return (
<div className='backstage-content'>
<BackstageHeader>
<Link to={`/${this.props.team.name}/integrations/outgoing_webhooks`}>
<FormattedMessage
id='installed_outgoing_webhooks.header'
defaultMessage='Outgoing Webhooks'
/>
</Link>
<FormattedMessage
id={headerToRender.id}
defaultMessage={headerToRender.defaultMessage}
/>
</BackstageHeader>
<div className='backstage-form'>
<form
className='form-horizontal'
onSubmit={this.handleSubmit}
>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='displayName'
>
<FormattedMessage
id='add_outgoing_webhook.displayName'
defaultMessage='Title'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='displayName'
type='text'
maxLength='64'
className='form-control'
value={this.state.displayName}
onChange={this.updateDisplayName}
/>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_webhook.displayName.help'
defaultMessage='Specify a title, of up to 64 characters, for the webhook settings page.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='description'
>
<FormattedMessage
id='add_outgoing_webhook.description'
defaultMessage='Description'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='description'
type='text'
maxLength='500'
className='form-control'
value={this.state.description}
onChange={this.updateDescription}
/>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_webhook.description.help'
defaultMessage='Describe your outgoing webhook.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='contentType'
>
<FormattedMessage
id='add_outgoing_webhook.content_Type'
defaultMessage='Content Type'
/>
</label>
<div className='col-md-5 col-sm-8'>
<select
className='form-control'
value={this.state.contentType}
onChange={this.updateContentType}
>
<option
value={contentTypeOption1}
>
{contentTypeOption1}
</option>
<option
value={contentTypeOption2}
>
{contentTypeOption2}
</option>
</select>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_webhook.contentType.help1'
defaultMessage='Specify the content type by which to send the request.'
/>
</div>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_webhook.contentType.help2'
defaultMessage='For the server to encode the parameters in a URL format in the request body, select application/x-www-form-urlencoded.'
/>
</div>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_webhook.contentType.help3'
defaultMessage='For the server to format the request body as JSON, select application/json.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='channelId'
>
<FormattedMessage
id='add_outgoing_webhook.channel'
defaultMessage='Channel'
/>
</label>
<div className='col-md-5 col-sm-8'>
<ChannelSelect
id='channelId'
value={this.state.channelId}
onChange={this.updateChannelId}
selectOpen={true}
/>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_webhook.channel.help'
defaultMessage='This field is optional if you specify at least one trigger word. Specify the public channel that delivers the payload to the webhook.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='triggerWords'
>
<FormattedMessage
id='add_outgoing_webhook.triggerWords'
defaultMessage='Trigger Words (One Per Line)'
/>
</label>
<div className='col-md-5 col-sm-8'>
<textarea
id='triggerWords'
rows='3'
maxLength='1000'
className='form-control'
value={this.state.triggerWords}
onChange={this.updateTriggerWords}
/>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_webhook.triggerWords.help'
defaultMessage='Specify the trigger words that send an HTTP POST request to your application. The trigger can be for the channel, the outgoing webhook, or both. If you select only Channel, trigger words are optional. If you select both, the message must match both values.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='triggerWords'
>
<FormattedMessage
id='add_outgoing_webhook.triggerWordsTriggerWhen'
defaultMessage='Trigger When'
/>
</label>
<div className='col-md-5 col-sm-8'>
<select
className='form-control'
value={this.state.triggerWhen}
onChange={this.updateTriggerWhen}
>
<option
value='0'
>
{localizeMessage('add_outgoing_webhook.triggerWordsTriggerWhenFullWord', 'First word matches a trigger word exactly')}
</option>
<option
value='1'
>
{localizeMessage('add_outgoing_webhook.triggerWordsTriggerWhenStartsWith', 'First word starts with a trigger word')}
</option>
</select>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_webhook.triggerWordsTriggerWhen.help'
defaultMessage='Specify when to trigger the outgoing webhook.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='callbackUrls'
>
<FormattedMessage
id='add_outgoing_webhook.callbackUrls'
defaultMessage='Callback URLs (One Per Line)'
/>
</label>
<div className='col-md-5 col-sm-8'>
<textarea
id='callbackUrls'
rows='3'
maxLength='1000'
className='form-control'
value={this.state.callbackUrls}
onChange={this.updateCallbackUrls}
/>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_webhook.callbackUrls.help'
defaultMessage='Specify the URL that the messages will be sent to. If the URL is private, add it as a {link}.'
values={{
link: (
<ExternalLink
href='https://docs.mattermost.com/configure/configuration-settings.html#session-lengths'
location='abstract_outgoing_webhook'
>
<FormattedMessage
id='add_outgoing_webhook.callbackUrls.helpLinkText'
defaultMessage='trusted internal connection'
/>
</ExternalLink>
),
}}
/>
</div>
</div>
</div>
{this.props.enablePostUsernameOverride &&
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='username'
>
<FormattedMessage
id='add_outgoing_webhook.username'
defaultMessage='Username'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='username'
type='text'
maxLength='22'
className='form-control'
value={this.state.username}
onChange={this.updateUsername}
/>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_webhook.username.help'
defaultMessage='Specify the username this integration will post as. Usernames can be up to 22 characters, and contain lowercase letters, numbers and the symbols \"-\", \"_\", and \".\". If left blank, the name specified by the webhook creator is used.'
/>
</div>
</div>
</div>
}
{this.props.enablePostIconOverride &&
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='iconURL'
>
<FormattedMessage
id='add_outgoing_webhook.icon_url'
defaultMessage='Profile Picture'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='iconURL'
type='text'
maxLength='1024'
className='form-control'
value={this.state.iconURL}
onChange={this.updateIconURL}
/>
<div className='form__help'>
<FormattedMessage
id='add_outgoing_webhook.icon_url.help'
defaultMessage='Enter the URL of a .png or .jpg file for this integration to use as the profile picture when posting. The file should be at least 128 pixels by 128 pixels. If left blank, the profile picture specified by the webhook creator is used.'
/>
</div>
</div>
</div>
}
<div className='backstage-form__footer'>
<FormError
type='backstage'
errors={[this.props.serverError, this.state.clientError]}
/>
<Link
className='btn btn-link btn-sm'
to={`/${this.props.team.name}/integrations/outgoing_webhooks`}
>
<FormattedMessage
id='add_outgoing_webhook.cancel'
defaultMessage='Cancel'
/>
</Link>
<SpinnerButton
className='btn btn-primary'
type='submit'
spinning={this.state.saving}
spinningText={localizeMessage(this.props.loading.id, this.props.loading.defaultMessage)}
onClick={this.handleSubmit}
id='saveWebhook'
>
<FormattedMessage
id={footerToRender.id}
defaultMessage={footerToRender.defaultMessage}
/>
</SpinnerButton>
{renderExtra}
</div>
</form>
</div>
</div>
);
}
}

Просмотреть файл

@@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import AbstractOutgoingWebhook from 'components/integrations/abstract_outgoing_webhook';
describe('components/integrations/AbstractOutgoingWebhook', () => {
const emptyFunction = jest.fn();
const props = {
team: {
id: 'test-team-id',
name: 'test',
},
action: emptyFunction,
enablePostUsernameOverride: false,
enablePostIconOverride: false,
header: {id: 'add', defaultMessage: 'add'},
footer: {id: 'save', defaultMessage: 'save'},
loading: {id: 'loading', defaultMessage: 'loading'},
renderExtra: '',
serverError: '',
};
test('should match snapshot', () => {
const wrapper = shallow(<AbstractOutgoingWebhook {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should render username in case of enablePostUsernameOverride is true ', () => {
const usernameTrueProps = {...props, enablePostUsernameOverride: true};
const wrapper = shallow(<AbstractOutgoingWebhook {...usernameTrueProps}/>);
expect(wrapper.find('#username')).toHaveLength(1);
});
test('should render username in case of enablePostUsernameOverride is true ', () => {
const iconUrlTrueProps = {...props, enablePostIconOverride: true};
const wrapper = shallow(<AbstractOutgoingWebhook {...iconUrlTrueProps}/>);
expect(wrapper.find('#iconURL')).toHaveLength(1);
});
});

Просмотреть файл

@@ -0,0 +1,46 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/AddCommand should match snapshot 1`] = `
<AbstractCommand
action={[Function]}
footer={
Object {
"defaultMessage": "Save",
"id": "add_command.save",
}
}
header={
Object {
"defaultMessage": "Add",
"id": "integrations.add",
}
}
loading={
Object {
"defaultMessage": "Saving...",
"id": "add_command.saving",
}
}
renderExtra=""
serverError=""
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "team_id",
"invite_id": "",
"name": "test",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
/>
`;

Просмотреть файл

@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import AddCommand from 'components/integrations/add_command/add_command';
import {TestHelper} from 'utils/test_helper';
describe('components/integrations/AddCommand', () => {
test('should match snapshot', () => {
const emptyFunction = jest.fn();
const team = TestHelper.getTeamMock({name: 'test'});
const wrapper = shallow(
<AddCommand
team={team}
actions={{addCommand: emptyFunction}}
/>,
);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {useHistory} from 'react-router-dom';
import {Command} from '@mattermost/types/integrations.js';
import {Team} from '@mattermost/types/teams.js';
import {ActionResult} from 'mattermost-redux/types/actions.js';
import {t} from 'utils/i18n';
import AbstractCommand from '../abstract_command.jsx';
const HEADER = {id: t('integrations.add'), defaultMessage: 'Add'};
const FOOTER = {id: t('add_command.save'), defaultMessage: 'Save'};
const LOADING = {id: t('add_command.saving'), defaultMessage: 'Saving...'};
export type Props = {
/**
* The team data
*/
team: Team;
actions: {
/**
* The function to call to add new command
*/
addCommand: (command: Command) => Promise<ActionResult>;
};
};
const AddCommand = ({team, actions}: Props) => {
const history = useHistory();
const [serverError, setServerError] = useState('');
const addCommand = async (command: Command) => {
setServerError('');
const {data, error} = await actions.addCommand(command);
if (data) {
history.push(`/${team.name}/integrations/commands/confirm?type=commands&id=${data.id}`);
return;
}
if (error) {
setServerError(error.message);
}
};
return (
<AbstractCommand
team={team}
header={HEADER}
footer={FOOTER}
loading={LOADING}
renderExtra={''}
action={addCommand}
serverError={serverError}
/>
);
};
export default AddCommand;

Просмотреть файл

@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators, ActionCreatorsMapObject, Dispatch} from 'redux';
import {ActionFunc} from 'mattermost-redux/types/actions';
import {addCommand} from 'mattermost-redux/actions/integrations';
import AddCommand, {Props} from './add_command';
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Props['actions']>({
addCommand,
}, dispatch),
};
}
export default connect(null, mapDispatchToProps)(AddCommand);

Просмотреть файл

@@ -0,0 +1,47 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/AddIncomingWebhook should match snapshot 1`] = `
<AbstractIncomingWebhook
action={[Function]}
enablePostIconOverride={true}
enablePostUsernameOverride={true}
footer={
Object {
"defaultMessage": "Save",
"id": "add_incoming_webhook.save",
}
}
header={
Object {
"defaultMessage": "Add",
"id": "integrations.add",
}
}
loading={
Object {
"defaultMessage": "Saving...",
"id": "add_incoming_webhook.saving",
}
}
serverError=""
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "testteamid",
"invite_id": "",
"name": "test",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
/>
`;

Просмотреть файл

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import AddIncomingWebhook from 'components/integrations/add_incoming_webhook/add_incoming_webhook';
import {TestHelper} from 'utils/test_helper';
describe('components/integrations/AddIncomingWebhook', () => {
const createIncomingHook = jest.fn().mockResolvedValue({data: true});
const props = {
team: TestHelper.getTeamMock({
id: 'testteamid',
name: 'test',
}),
enablePostUsernameOverride: true,
enablePostIconOverride: true,
actions: {createIncomingHook},
};
test('should match snapshot', () => {
const wrapper = shallow(<AddIncomingWebhook {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should have called createIncomingHook', () => {
const hook = TestHelper.getIncomingWebhookMock({
channel_id: 'channel_id',
display_name: 'display_name',
description: 'description',
username: 'username',
icon_url: 'icon_url',
});
const wrapper = shallow<AddIncomingWebhook>(<AddIncomingWebhook {...props}/>);
wrapper.instance().addIncomingHook(hook);
expect(createIncomingHook).toHaveBeenCalledTimes(1);
expect(createIncomingHook).toBeCalledWith(hook);
});
});

Просмотреть файл

@@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {getHistory} from 'utils/browser_history';
import {t} from 'utils/i18n';
import AbstractIncomingWebhook from 'components/integrations/abstract_incoming_webhook';
import {Team} from '@mattermost/types/teams';
import {IncomingWebhook} from '@mattermost/types/integrations';
const HEADER = {id: t('integrations.add'), defaultMessage: 'Add'};
const FOOTER = {id: t('add_incoming_webhook.save'), defaultMessage: 'Save'};
const LOADING = {id: t('add_incoming_webhook.saving'), defaultMessage: 'Saving...'};
type Props = {
/**
* The current team
*/
team: Team;
/**
* Whether to allow configuration of the default post username.
*/
enablePostUsernameOverride: boolean;
/**
* Whether to allow configuration of the default post icon.
*/
enablePostIconOverride: boolean;
actions: {
/**
* The function to call to add a new incoming webhook
*/
createIncomingHook: (hook: IncomingWebhook) => Promise<{ data?: IncomingWebhook; error?: Error }>;
};
};
type State = {
serverError: string;
};
export default class AddIncomingWebhook extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
serverError: '',
};
}
addIncomingHook = async (hook: IncomingWebhook) => {
this.setState({serverError: ''});
const {data, error} = await this.props.actions.createIncomingHook(hook);
if (data) {
getHistory().push(`/${this.props.team.name}/integrations/confirm?type=incoming_webhooks&id=${data.id}`);
return;
}
if (error) {
this.setState({serverError: error.message});
}
}
render() {
return (
<AbstractIncomingWebhook
team={this.props.team}
header={HEADER}
footer={FOOTER}
loading={LOADING}
enablePostUsernameOverride={this.props.enablePostUsernameOverride}
enablePostIconOverride={this.props.enablePostIconOverride}
action={this.addIncomingHook}
serverError={this.state.serverError}
/>
);
}
}

Просмотреть файл

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {createIncomingHook} from 'mattermost-redux/actions/integrations';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {GlobalState} from '@mattermost/types/store';
import {Action, GenericAction} from 'mattermost-redux/types/actions';
import {IncomingWebhook} from '@mattermost/types/integrations';
import AddIncomingWebhook from './add_incoming_webhook';
function mapStateToProps(state: GlobalState) {
const config = getConfig(state);
const enablePostUsernameOverride = config.EnablePostUsernameOverride === 'true';
const enablePostIconOverride = config.EnablePostIconOverride === 'true';
return {
enablePostUsernameOverride,
enablePostIconOverride,
};
}
type Actions = {
createIncomingHook: (hook: IncomingWebhook) => Promise<{ data?: IncomingWebhook; error?: Error }>;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<Action>, Actions>({
createIncomingHook,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(AddIncomingWebhook);

Просмотреть файл

@@ -0,0 +1,46 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/AddOAuthApp should match snapshot 1`] = `
<AbstractOAuthApp
action={[Function]}
footer={
Object {
"defaultMessage": "Save",
"id": "installed_oauth_apps.save",
}
}
header={
Object {
"defaultMessage": "Add",
"id": "add_oauth_app.header",
}
}
loading={
Object {
"defaultMessage": "Saving...",
"id": "installed_oauth_apps.saving",
}
}
renderExtra=""
serverError=""
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "dbcxd9wpzpbpfp8pad78xj12pr",
"invite_id": "",
"name": "test",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
/>
`;

Просмотреть файл

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import AddOAuthApp from 'components/integrations/add_oauth_app/add_oauth_app';
import {TestHelper} from 'utils/test_helper';
describe('components/integrations/AddOAuthApp', () => {
const emptyFunction = jest.fn();
const team = TestHelper.getTeamMock({
id: 'dbcxd9wpzpbpfp8pad78xj12pr',
name: 'test',
});
test('should match snapshot', () => {
const wrapper = shallow(
<AddOAuthApp
team={team}
actions={{addOAuthApp: emptyFunction}}
/>,
);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {useHistory} from 'react-router-dom';
import {OAuthApp} from '@mattermost/types/integrations.js';
import {Team} from '@mattermost/types/teams.js';
import {ActionResult} from 'mattermost-redux/types/actions.js';
import {t} from 'utils/i18n';
import AbstractOAuthApp from '../abstract_oauth_app.jsx';
const HEADER = {id: t('add_oauth_app.header'), defaultMessage: 'Add'};
const FOOTER = {id: t('installed_oauth_apps.save'), defaultMessage: 'Save'};
const LOADING = {id: t('installed_oauth_apps.saving'), defaultMessage: 'Saving...'};
export type Props = {
/**
* The team data
*/
team: Team;
actions: {
/**
* The function to call to add new OAuthApp
*/
addOAuthApp: (app: OAuthApp) => Promise<ActionResult>;
};
};
const AddOAuthApp = ({team, actions}: Props): JSX.Element => {
const history = useHistory();
const [serverError, setServerError] = useState('');
const addOAuthApp = async (app: OAuthApp) => {
setServerError('');
const {data, error} = await actions.addOAuthApp(app);
if (data) {
history.push(`/${team.name}/integrations/confirm?type=oauth2-apps&id=${data.id}`);
return;
}
if (error) {
setServerError(error.message);
}
};
return (
<AbstractOAuthApp
team={team}
header={HEADER}
footer={FOOTER}
loading={LOADING}
renderExtra={''}
action={addOAuthApp}
serverError={serverError}
/>
);
};
export default AddOAuthApp;

Просмотреть файл

@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {addOAuthApp} from 'mattermost-redux/actions/integrations';
import {ActionFunc} from 'mattermost-redux/types/actions';
import AddOAuthApp, {Props} from './add_oauth_app';
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Props['actions']>({
addOAuthApp,
}, dispatch),
};
}
export default connect(null, mapDispatchToProps)(AddOAuthApp);

Просмотреть файл

@@ -0,0 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/AddOutgoingWebhook should match snapshot 1`] = `
<AbstractOutgoingWebhook
action={[Function]}
enablePostIconOverride={false}
enablePostUsernameOverride={false}
footer={
Object {
"defaultMessage": "Save",
"id": "add_outgoing_webhook.save",
}
}
header={
Object {
"defaultMessage": "Add",
"id": "integrations.add",
}
}
loading={
Object {
"defaultMessage": "Saving...",
"id": "add_outgoing_webhook.saving",
}
}
renderExtra=""
serverError=""
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "testteamid",
"invite_id": "",
"name": "test",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
/>
`;

Просмотреть файл

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import AddOutgoingWebhook from 'components/integrations/add_outgoing_webhook/add_outgoing_webhook';
import {TestHelper} from 'utils/test_helper';
describe('components/integrations/AddOutgoingWebhook', () => {
test('should match snapshot', () => {
const emptyFunction = jest.fn();
const team = TestHelper.getTeamMock({
id: 'testteamid',
name: 'test',
});
const wrapper = shallow(
<AddOutgoingWebhook
team={team}
actions={{createOutgoingHook: emptyFunction}}
enablePostUsernameOverride={false}
enablePostIconOverride={false}
/>,
);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {useHistory} from 'react-router-dom';
import {t} from 'utils/i18n';
import AbstractOutgoingWebhook from 'components/integrations/abstract_outgoing_webhook.jsx';
import {Team} from '@mattermost/types/teams';
import {OutgoingWebhook} from '@mattermost/types/integrations';
import {ActionResult} from 'mattermost-redux/types/actions';
const HEADER = {id: t('integrations.add'), defaultMessage: 'Add'};
const FOOTER = {id: t('add_outgoing_webhook.save'), defaultMessage: 'Save'};
const LOADING = {id: t('add_outgoing_webhook.saving'), defaultMessage: 'Saving...'};
export type Props = {
/**
* The current team
*/
team: Team;
actions: {
/**
* The function to call to add a new outgoing webhook
*/
createOutgoingHook: (hook: OutgoingWebhook) => Promise<ActionResult>;
};
/**
* Whether to allow configuration of the default post username.
*/
enablePostUsernameOverride: boolean;
/**
* Whether to allow configuration of the default post icon.
*/
enablePostIconOverride: boolean;
};
const AddOutgoingWebhook = ({team, actions, enablePostUsernameOverride, enablePostIconOverride}: Props): JSX.Element => {
const history = useHistory();
const [serverError, setServerError] = useState('');
const addOutgoingHook = async (hook: OutgoingWebhook) => {
setServerError('');
const {data, error} = await actions.createOutgoingHook(hook);
if (data) {
history.push(`/${team.name}/integrations/confirm?type=outgoing_webhooks&id=${data.id}`);
return;
}
if (error) {
setServerError(error.message);
}
};
return (
<AbstractOutgoingWebhook
team={team}
header={HEADER}
footer={FOOTER}
loading={LOADING}
renderExtra={''}
action={addOutgoingHook}
serverError={serverError}
enablePostUsernameOverride={enablePostUsernameOverride}
enablePostIconOverride={enablePostIconOverride}
/>
);
};
export default AddOutgoingWebhook;

Просмотреть файл

@@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {createOutgoingHook} from 'mattermost-redux/actions/integrations';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {ActionFunc} from 'mattermost-redux/types/actions';
import {GlobalState} from 'types/store';
import AddOutgoingWebhook, {Props} from './add_outgoing_webhook';
function mapStateToProps(state: GlobalState) {
const config = getConfig(state);
const enablePostUsernameOverride = config.EnablePostUsernameOverride === 'true';
const enablePostIconOverride = config.EnablePostIconOverride === 'true';
return {
enablePostUsernameOverride,
enablePostIconOverride,
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Props['actions']>({
createOutgoingHook,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(AddOutgoingWebhook);

Просмотреть файл

@@ -0,0 +1,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {FormattedMessage} from 'react-intl';
// import TestHelper from 'mattermost-redux/test/test_helper';
import {TestHelper} from 'utils/test_helper';
import AddBot from './add_bot';
describe('components/integrations/bots/AddBot', () => {
const team = TestHelper.getTeamMock();
const actions = {
createBot: jest.fn(),
patchBot: jest.fn(),
uploadProfileImage: jest.fn(),
setDefaultProfileImage: jest.fn(),
createUserAccessToken: jest.fn(),
updateUserRoles: jest.fn(),
};
it('blank', () => {
const wrapper = shallow(
<AddBot
maxFileSize={100}
team={team}
editingUserHasManageSystem={true}
actions={actions}
/>,
);
expect(wrapper.containsMatchingElement(
<input
id='username'
value={''}
/>,
)).toEqual(true);
expect(wrapper.containsMatchingElement(
<input
id='displayName'
value={''}
/>,
)).toEqual(true);
expect(wrapper.containsMatchingElement(
<input
id='description'
value={''}
/>,
)).toEqual(true);
expect(wrapper.containsMatchingElement(
<FormattedMessage
id='admin.manage_roles.additionalRoles'
/>,
)).toEqual(true);
});
it('edit bot', () => {
const bot = TestHelper.getBotMock({});
const wrapper = shallow(
<AddBot
bot={bot}
maxFileSize={100}
team={team}
editingUserHasManageSystem={true}
actions={actions}
/>,
);
expect(wrapper.containsMatchingElement(
<input
id='username'
value={bot.username}
/>,
)).toEqual(true);
expect(wrapper.containsMatchingElement(
<input
id='displayName'
value={bot.display_name}
/>,
)).toEqual(true);
expect(wrapper.containsMatchingElement(
<input
id='description'
value={bot.description}
/>,
)).toEqual(true);
});
});

Просмотреть файл

@@ -0,0 +1,744 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ChangeEvent, FormEvent} from 'react';
import {Link} from 'react-router-dom';
import {FormattedMessage} from 'react-intl';
import * as FileUtils from 'utils/file_utils';
import * as UserUtils from 'mattermost-redux/utils/user_utils';
import {General} from 'mattermost-redux/constants';
import BotDefaultIcon from 'images/bot_default_icon.png';
import BackstageHeader from 'components/backstage/components/backstage_header';
import OverlayTrigger from 'components/overlay_trigger';
import Tooltip from 'components/tooltip';
import SpinnerButton from 'components/spinner_button';
import FormError from 'components/form_error';
import {getHistory} from 'utils/browser_history';
import {AcceptedProfileImageTypes, Constants, ValidationErrors} from 'utils/constants';
import * as Utils from 'utils/utils';
import {Team} from '@mattermost/types/teams';
import {Bot, BotPatch} from '@mattermost/types/bots';
import {UserProfile} from '@mattermost/types/users';
import {ActionResult} from 'mattermost-redux/types/actions';
import ExternalLink from 'components/external_link';
const roleOptionSystemAdmin = 'System Admin';
const roleOptionMember = 'Member';
export type Props = {
/**
* Only used for routing since backstage is team based.
*/
team: Team;
/**
* Bot to edit (if editing)
*/
bot?: Bot;
/**
* Bot user
*/
user?: UserProfile;
/**
* Roles of the bot to edit (if editing)
*/
roles?: string;
/**
* Maximum upload file size (for bot account profile picture)
*/
maxFileSize: number;
/**
* Editing user has the MANAGE_SYSTEM permission
*/
editingUserHasManageSystem: boolean;
/**
* Bot to edit
*/
actions: {
/**
* Creates a new bot account.
*/
createBot: (bot: Partial<Bot>) => ActionResult;
/**
* Patches an existing bot account.
*/
patchBot: (botUserId: string, botPatch: Partial<BotPatch>) => ActionResult;
/**
* Uploads a user profile image
*/
uploadProfileImage: (userId: string, image: File | string) => ActionResult;
/**
* Set profile image to default
*/
setDefaultProfileImage: (userId: string) => ActionResult;
/**
* For creating default access token
*/
createUserAccessToken: (userId: string, description: string) => ActionResult;
/**
* For creating setting bot to system admin or special posting permissions
*/
updateUserRoles: (userId: string, roles: string) => ActionResult;
};
};
export type State = {
username: string;
displayName: string | undefined;
description: string | undefined;
role: string;
postAll: boolean;
postChannels: boolean;
error: JSX.Element | string;
adding: boolean;
image: string;
orientationStyles: {transform: string; transformOrigin: string};
pictureFile: File | null | string;
};
export default class AddBot extends React.PureComponent<Props, State> {
previewBlob: string | null = null;
constructor(props: Props) {
super(props);
this.state = {
error: '',
username: this.props.bot ? this.props.bot.username : '',
displayName: this.props.bot ? this.props.bot.display_name : '',
description: this.props.bot ? this.props.bot.description : '',
adding: false,
image: BotDefaultIcon,
role: UserUtils.isSystemAdmin(this.props.roles || '') ? roleOptionSystemAdmin : roleOptionMember,
postAll: this.props.bot ? UserUtils.hasPostAllRole(this.props.roles || '') : false,
postChannels: this.props.bot ? UserUtils.hasPostAllPublicRole(this.props.roles || '') : false,
orientationStyles: {transform: '', transformOrigin: ''},
pictureFile: null,
};
}
updateUsername = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
username: e.target.value,
});
}
updateDisplayName = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
displayName: e.target.value,
});
}
updateDescription = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
description: e.target.value,
});
}
updateRole = (e: ChangeEvent<HTMLSelectElement>) => {
this.setState({
role: e.target.value,
});
}
updatePostAll = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
postAll: e.target.checked,
});
}
updatePostChannels = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
postChannels: e.target.checked,
});
}
updatePicture = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const pictureFile = e.target.files[0];
this.previewBlob = URL.createObjectURL(pictureFile);
const reader = new FileReader();
reader.onload = (e2) => {
const orientation = FileUtils.getExifOrientation(e2.target?.result as ArrayBuffer);
const orientationStyles = FileUtils.getOrientationStyles(orientation);
this.setState({
image: this.previewBlob || '',
orientationStyles,
});
};
reader.readAsArrayBuffer(pictureFile);
e.target.value = '';
this.setState({pictureFile});
} else {
this.setState({pictureFile: null, image: ''});
}
}
setDefault = () => {
this.setState({pictureFile: 'default', image: BotDefaultIcon});
}
isFile(file: File | string): file is File {
return (file as File).size !== undefined;
}
updateRoles = async (data: Bot) => {
let roles = General.SYSTEM_USER_ROLE;
if (this.state.role === roleOptionSystemAdmin) {
roles += ' ' + General.SYSTEM_ADMIN_ROLE;
} else if (this.state.postAll) {
roles += ' ' + General.SYSTEM_POST_ALL_ROLE;
} else if (this.state.postChannels) {
roles += ' ' + General.SYSTEM_POST_ALL_PUBLIC_ROLE;
}
const rolesResult = await this.props.actions.updateUserRoles(data.user_id, roles);
if (rolesResult) {
return rolesResult.error;
}
return null;
}
handleSubmit = async (e: FormEvent<HTMLButtonElement | HTMLFormElement>) => {
e.preventDefault();
if (this.state.adding) {
return;
}
if (!this.state.username || this.state.username.length < 3) {
this.setState({
error: (
<FormattedMessage
id='bots.manage.add.invalid_username'
defaultMessage='Usernames have to begin with a lowercase letter and be 3-22 characters long. You can use lowercase letters, numbers, periods, dashes, and underscores.'
/>
),
});
return;
}
if (this.state.pictureFile && this.isFile(this.state.pictureFile)) {
if (!AcceptedProfileImageTypes.includes(this.state.pictureFile.type)) {
this.setState({
error: (
<FormattedMessage
id='user.settings.general.validImage'
defaultMessage='Only BMP, JPG or PNG images may be used for profile pictures'
/>
),
});
} else if (this.state.pictureFile.size > this.props.maxFileSize) {
this.setState({
error: (
<FormattedMessage
id='user.settings.general.imageTooLarge'
defaultMessage='Unable to upload profile image. File is too large.'
/>
),
});
}
}
this.setState({
adding: true,
error: '',
});
const bot = {
username: this.state.username.toLowerCase().trim(),
display_name: this.state.displayName,
description: this.state.description,
};
let data: Bot | undefined;
let error;
if (this.props.bot) {
const result = await this.props.actions.patchBot(this.props.bot.user_id, bot);
if (result) {
data = result.data;
error = result.error;
} else {
error = Utils.localizeMessage('bot.edit_failed', 'Failed to edit bot');
}
if (!error && data) {
if (this.state.pictureFile && this.state.pictureFile !== 'default') {
const imageResult = await this.props.actions.uploadProfileImage(data.user_id, this.state.pictureFile);
error = imageResult.error;
} else if (this.state.pictureFile && this.state.pictureFile === 'default') {
await this.props.actions.setDefaultProfileImage(data.user_id);
}
}
if (!error && data) {
error = this.updateRoles(data);
}
if (data) {
getHistory().push(`/${this.props.team.name}/integrations/bots`);
return;
}
} else {
const usernameError = Utils.isValidBotUsername(bot.username);
if (usernameError) {
let errObj;
if (usernameError.id === ValidationErrors.INVALID_LAST_CHARACTER) {
errObj = {
adding: false,
error: (
<FormattedMessage
id='bots.manage.add.invalid_last_char'
defaultMessage='Bot usernames cannot have a period as the last character'
/>
),
};
} else {
errObj = {
adding: false,
error: (
<FormattedMessage
id='bots.manage.add.invalid_username'
defaultMessage='Usernames must begin with a lowercase letter and be 3-22 characters long. You can use lowercase letters, numbers, periods, dashes, and underscores.'
/>
),
};
}
this.setState(errObj);
return;
}
const result = await this.props.actions.createBot(bot);
if (result) {
data = result.data;
error = result.error;
} else {
error = Utils.localizeMessage('bot.create_failed', 'Failed to create bot');
}
let token = '';
if (!error && data) {
if (this.state.pictureFile && this.state.pictureFile !== 'default') {
await this.props.actions.uploadProfileImage(data.user_id, this.state.pictureFile);
} else {
await this.props.actions.setDefaultProfileImage(data.user_id);
}
const tokenResult = await this.props.actions.createUserAccessToken(data.user_id,
Utils.localizeMessage('bot.token.default.description', 'Default Token'),
);
// On error just skip the confirmation because we have a bot without a token.
if (!tokenResult || tokenResult.error) {
getHistory().push(`/${this.props.team.name}/integrations/bots`);
return;
}
token = tokenResult.data.token;
}
if (!error && data) {
error = this.updateRoles(data);
}
if (data) {
getHistory().push(`/${this.props.team.name}/integrations/confirm?type=bots&id=${data.user_id}&token=${token}`);
return;
}
}
this.setState({
adding: false,
});
if (error) {
this.setState({
error: error.message,
});
}
}
render() {
let subtitle = (
<FormattedMessage
id='bots.manage.add'
defaultMessage='Add'
/>
);
let buttonText = (
<FormattedMessage
id='bots.manage.add.create'
defaultMessage='Create Bot Account'
/>
);
let buttonActiveText = (
<FormattedMessage
id='bots.manage.add.creating'
defaultMessage='Creating...'
/>
);
// If we are editing
if (this.props.bot) {
subtitle = (
<FormattedMessage
id='bots.manage.edit'
defaultMessage='Edit'
/>
);
buttonText = (
<FormattedMessage
id='bots.manage.edit.title'
defaultMessage='Update'
/>
);
buttonActiveText = (
<FormattedMessage
id='bots.manage.edit.editing'
defaultMessage='Updating...'
/>
);
}
let imageURL = '';
let removeImageIcon: JSX.Element | null = (
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}
placement='right'
overlay={(
<Tooltip id='removeIcon'>
<FormattedMessage
id='bot.remove_profile_picture'
defaultMessage='Remove Bot Icon'
/>
</Tooltip>
)}
>
<a
className={'bot-profile__remove'}
onClick={this.setDefault}
>
<span>{'×'}</span>
</a>
</OverlayTrigger>
);
let imageStyles;
if (this.props.bot && !this.state.pictureFile) {
if (this.props.user) {
imageURL = Utils.imageURLForUser(this.props.user.id, this.props.user.last_picture_update);
} else {
imageURL = Utils.imageURLForUser(this.props.bot.user_id);
}
} else {
imageURL = this.state.image;
imageStyles = this.state.orientationStyles;
if (this.state.image === BotDefaultIcon) {
removeImageIcon = null;
}
}
return (
<div className='backstage-content'>
<BackstageHeader>
<Link to={`/${this.props.team.name}/integrations/bots`}>
<FormattedMessage
id='bots.manage.header'
defaultMessage='Bot Accounts'
/>
</Link>
{subtitle}
</BackstageHeader>
<div className='backstage-form'>
<form
className='form-horizontal'
onSubmit={this.handleSubmit}
>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='username'
>
<FormattedMessage
id='bots.add.username'
defaultMessage='Username'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='username'
type='text'
maxLength={22}
className='form-control'
value={this.state.username}
onChange={this.updateUsername}
/>
<div className='form__help'>
<FormattedMessage
id='bot.add.username.help'
defaultMessage='You can use lowercase letters, numbers, periods, dashes, and underscores.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='boticon'
>
<FormattedMessage
id='bots.add.icon'
defaultMessage='Bot Icon'
/>
</label>
<div className='col-md-5 col-sm-8'>
<div className={'bot-img-container'}>
<img
className={'bot-img'}
alt={'bot image'}
src={imageURL}
style={imageStyles}
/>
{removeImageIcon}
</div>
<div
className='btn btn-sm btn-primary btn-file sel-btn'
>
<FormattedMessage
id='bots.image.upload'
defaultMessage='Upload Image'
/>
<input
accept={Constants.ACCEPT_STATIC_IMAGE}
type='file'
onChange={this.updatePicture}
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='displayName'
>
<FormattedMessage
id='bots.add.displayName'
defaultMessage='Display Name'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='displayName'
type='text'
maxLength={64}
className='form-control'
value={this.state.displayName}
onChange={this.updateDisplayName}
/>
<div className='form__help'>
<FormattedMessage
id='bot.add.display_name.help'
defaultMessage={'(Optional) You can choose to display your bot\'s full name rather than its username.'}
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='description'
>
<FormattedMessage
id='bot.add.description'
defaultMessage='Description'
/>
</label>
<div className='col-md-5 col-sm-8'>
<input
id='description'
type='text'
maxLength={1024}
className='form-control'
value={this.state.description}
onChange={this.updateDescription}
/>
<div className='form__help'>
<FormattedMessage
id='bot.add.description.help'
defaultMessage='(Optional) Let others know what this bot does.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='role'
>
<FormattedMessage
id='bot.add.role'
defaultMessage='Role'
/>
</label>
<div className='col-md-5 col-sm-8'>
<select
className='form-control'
value={this.state.role}
disabled={!this.props.editingUserHasManageSystem}
onChange={this.updateRole}
>
<option
value={roleOptionMember}
>
{Utils.localizeMessage('bot.add.role.member', 'Member')}
</option>
<option
value={roleOptionSystemAdmin}
>
{Utils.localizeMessage('bot.add.role.admin', 'System Admin')}
</option>
</select>
<div className='form__help'>
<FormattedMessage
id='bot.add.role.help'
defaultMessage='Choose what role the bot should have.'
/>
</div>
</div>
</div>
<div className='row bot-profile__section'>
<div className='col-md-5 col-sm-8 col-sm-offset-4'>
<FormattedMessage
id='admin.manage_roles.additionalRoles'
defaultMessage='Select additional permissions for the account. <link>Read more about roles and permissions</link>.'
values={{
link: (msg: React.ReactNode) => (
<ExternalLink
href='https://developers.mattermost.com/integrate/admin-guide/admin-personal-access-token/'
location='add_bot'
>
{msg}
</ExternalLink>
),
}}
/>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='postAll'
>
<FormattedMessage
id='bot.add.post_all'
defaultMessage='post:all'
/>
</label>
<div className='col-md-5 col-sm-8 checkbox'>
<div className='checkbox no-padding'>
<label htmlFor='postAll'>
<input
id='postAll'
type='checkbox'
checked={this.state.postAll || this.state.role === roleOptionSystemAdmin}
onChange={this.updatePostAll}
disabled={!this.props.editingUserHasManageSystem || this.state.role === roleOptionSystemAdmin}
/>
<FormattedMessage
id='bot.add.post_all.enabled'
defaultMessage='Enabled'
/>
</label>
</div>
<div className='form__help'>
<FormattedMessage
id='bot.add.post_all.help'
defaultMessage='Bot will have access to post to all Mattermost channels including direct messages.'
/>
</div>
</div>
</div>
<div className='form-group'>
<label
className='control-label col-sm-4'
htmlFor='postChannels'
>
<FormattedMessage
id='bot.add.post_channels'
defaultMessage='post:channels'
/>
</label>
<div className='col-md-5 col-sm-8 checkbox'>
<div className='checkbox no-padding'>
<label htmlFor='postChannels'>
<input
id='postChannels'
type='checkbox'
checked={this.state.postChannels || this.state.role === roleOptionSystemAdmin || this.state.postAll}
onChange={this.updatePostChannels}
disabled={!this.props.editingUserHasManageSystem || this.state.role === roleOptionSystemAdmin || this.state.postAll}
/>
<FormattedMessage
id='bot.add.post_channels.enabled'
defaultMessage='Enabled'
/>
</label>
</div>
<div className='form__help'>
<FormattedMessage
id='bot.add.post_channels.help'
defaultMessage='Bot will have access to post to all Mattermost public channels.'
/>
</div>
</div>
</div>
<div className='backstage-form__footer'>
<FormError
type='backstage'
errors={[this.state.error]}
/>
<Link
className='btn btn-link btn-sm'
to={`/${this.props.team.name}/integrations/bots`}
>
<FormattedMessage
id='bots.manage.add.cancel'
defaultMessage='Cancel'
/>
</Link>
<SpinnerButton
className='btn btn-primary'
type='submit'
spinning={this.state.adding}
spinningText={buttonActiveText}
onClick={this.handleSubmit}
id='saveBot'
>
{buttonText}
</SpinnerButton>
</div>
</form>
</div>
</div>
);
}
}

Просмотреть файл

@@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {RouteComponentProps} from 'react-router-dom';
import {updateUserRoles, uploadProfileImage, setDefaultProfileImage, createUserAccessToken} from 'mattermost-redux/actions/users';
import {createBot, patchBot} from 'mattermost-redux/actions/bots';
import {getBotAccounts} from 'mattermost-redux/selectors/entities/bots';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {haveISystemPermission} from 'mattermost-redux/selectors/entities/roles';
import {Permissions} from 'mattermost-redux/constants';
import {ActionFunc} from 'mattermost-redux/types/actions';
import {GlobalState} from 'types/store';
import AddBot, {Props} from './add_bot';
type OwnProps = {
/**
* Search query for the bot
*/
location: RouteComponentProps['location'];
}
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const config = getConfig(state);
const botId = (new URLSearchParams(ownProps.location.search)).get('id');
const bots = getBotAccounts(state);
const bot = (bots && botId) ? bots[botId] : undefined;
const user = bot ? getUser(state, bot.user_id) : undefined;
const roles = user ? user.roles : undefined;
return {
maxFileSize: parseInt(config.MaxFileSize!, 10),
bot,
roles,
editingUserHasManageSystem: haveISystemPermission(state, {permission: Permissions.MANAGE_SYSTEM}),
user,
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Props['actions']>({
createBot,
patchBot,
uploadProfileImage,
setDefaultProfileImage,
createUserAccessToken,
updateUserRoles,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(AddBot);

Просмотреть файл

@@ -0,0 +1,277 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {FormattedMessage} from 'react-intl';
import Markdown from 'components/markdown';
import {TestHelper as UtilsTestHelper} from 'utils/test_helper';
import {generateId} from 'mattermost-redux/utils/helpers';
import Bot from './bot';
describe('components/integrations/bots/Bot', () => {
const team = UtilsTestHelper.getTeamMock();
const actions = {
disableBot: jest.fn(),
enableBot: jest.fn(),
createUserAccessToken: jest.fn(),
revokeUserAccessToken: jest.fn(),
enableUserAccessToken: jest.fn(),
disableUserAccessToken: jest.fn(),
};
it('regular bot', () => {
const bot = UtilsTestHelper.getBotMock({user_id: '1'});
const user = UtilsTestHelper.getUserMock({id: bot.user_id});
const wrapper = shallow(
<Bot
bot={bot}
user={user}
owner={undefined}
accessTokens={{}}
team={team}
actions={actions}
fromApp={false}
/>,
);
expect(wrapper.contains(bot.display_name + ' (@' + bot.username + ')')).toEqual(true);
expect(wrapper.contains(<Markdown message={bot.description}/>)).toEqual(true);
expect(wrapper.contains('plugin')).toEqual(true);
// if bot managed by plugin, remove ability to edit from UI
expect(wrapper.contains(
<FormattedMessage
id='bot.manage.create_token'
defaultMessage='Create New Token'
/>,
)).toEqual(false);
expect(wrapper.contains(
<FormattedMessage
id='bots.manage.edit'
defaultMessage='Edit'
/>,
)).toEqual(false);
expect(wrapper.contains(
<FormattedMessage
id='bot.manage.disable'
defaultMessage='Disable'
/>,
)).toEqual(false);
expect(wrapper.contains(
<FormattedMessage
id='bot.manage.enable'
defaultMessage='Enable'
/>,
)).toEqual(false);
});
it('app bot', () => {
const bot = UtilsTestHelper.getBotMock({user_id: '1'});
const user = UtilsTestHelper.getUserMock({id: bot.user_id});
const wrapper = shallow(
<Bot
bot={bot}
user={user}
owner={undefined}
accessTokens={{}}
team={team}
actions={actions}
fromApp={true}
/>,
);
expect(wrapper.contains(bot.display_name + ' (@' + bot.username + ')')).toEqual(true);
expect(wrapper.contains(<Markdown message={bot.description}/>)).toEqual(true);
expect(wrapper.contains('Apps Framework')).toEqual(true);
// if bot managed by plugin, remove ability to edit from UI
expect(wrapper.contains(
<FormattedMessage
id='bot.manage.create_token'
defaultMessage='Create New Token'
/>,
)).toEqual(true);
expect(wrapper.contains(
<FormattedMessage
id='bots.manage.edit'
defaultMessage='Edit'
/>,
)).toEqual(true);
expect(wrapper.contains(
<FormattedMessage
id='bot.manage.disable'
defaultMessage='Disable'
/>,
)).toEqual(true);
expect(wrapper.contains(
<FormattedMessage
id='bot.manage.enable'
defaultMessage='Enable'
/>,
)).toEqual(false);
});
it('disabled bot', () => {
const bot = UtilsTestHelper.getBotMock({user_id: '1'});
bot.delete_at = 100; // disabled
const user = UtilsTestHelper.getUserMock({id: bot.user_id});
const wrapper = shallow(
<Bot
bot={bot}
user={user}
owner={undefined}
accessTokens={{}}
team={team}
actions={actions}
fromApp={false}
/>,
);
expect(wrapper.contains(bot.display_name + ' (@' + bot.username + ')')).toEqual(true);
expect(wrapper.contains(<Markdown message={bot.description}/>)).toEqual(true);
expect(wrapper.contains('plugin')).toEqual(true);
expect(wrapper.contains(
<FormattedMessage
id='bot.manage.create_token'
defaultMessage='Create New Token'
/>,
)).toEqual(false);
expect(wrapper.contains(
<FormattedMessage
id='bots.manage.edit'
defaultMessage='Edit'
/>,
)).toEqual(false);
expect(wrapper.contains(
<FormattedMessage
id='bot.manage.disable'
defaultMessage='Disable'
/>,
)).toEqual(false);
expect(wrapper.contains(
<FormattedMessage
id='bot.manage.enable'
defaultMessage='Enable'
/>,
)).toEqual(true);
});
it('bot with owner', () => {
const bot = UtilsTestHelper.getBotMock({user_id: '1', owner_id: '1'});
const owner = UtilsTestHelper.getUserMock({id: bot.owner_id});
const user = UtilsTestHelper.getUserMock({id: bot.user_id});
const wrapper = shallow(
<Bot
bot={bot}
owner={owner}
user={user}
accessTokens={{}}
team={team}
actions={actions}
fromApp={false}
/>,
);
expect(wrapper.contains(owner.username)).toEqual(true);
expect(wrapper.contains('plugin')).toEqual(false);
// if bot is not managed by plugin, ability to edit from UI is retained
expect(wrapper.contains(
<FormattedMessage
id='bot.manage.create_token'
defaultMessage='Create New Token'
/>,
)).toEqual(true);
expect(wrapper.contains(
<FormattedMessage
id='bots.manage.edit'
defaultMessage='Edit'
/>,
)).toEqual(true);
expect(wrapper.contains(
<FormattedMessage
id='bot.manage.disable'
defaultMessage='Disable'
/>,
)).toEqual(true);
});
it('bot with access tokens', () => {
const bot = UtilsTestHelper.getBotMock({user_id: '1'});
const tokenId = generateId();
const user = UtilsTestHelper.getUserMock({id: bot.user_id});
const accessTokens = {
tokenId: UtilsTestHelper.getUserAccessTokenMock({
id: tokenId,
user_id: bot.user_id,
}),
};
const wrapper = shallow(
<Bot
bot={bot}
owner={undefined}
user={user}
accessTokens={accessTokens}
team={team}
actions={actions}
fromApp={false}
/>,
);
expect(wrapper.contains(tokenId)).toEqual(true);
expect(wrapper.contains(
<FormattedMessage
id='user.settings.tokens.deactivate'
defaultMessage='Disable'
/>,
)).toEqual(true);
expect(wrapper.contains(
<FormattedMessage
id='user.settings.tokens.activate'
defaultMessage='Enable'
/>,
)).toEqual(false);
});
it('bot with disabled access tokens', () => {
const bot = UtilsTestHelper.getBotMock({user_id: '1'});
const tokenId = generateId();
const user = UtilsTestHelper.getUserMock({id: bot.user_id});
const accessTokens = {
tokenId: UtilsTestHelper.getUserAccessTokenMock({
id: tokenId,
user_id: bot.user_id,
is_active: false,
}),
};
const wrapper = shallow(
<Bot
bot={bot}
owner={undefined}
user={user}
accessTokens={accessTokens}
team={team}
actions={actions}
fromApp={false}
/>,
);
expect(wrapper.contains(tokenId)).toEqual(true);
expect(wrapper.contains(
<FormattedMessage
id='user.settings.tokens.deactivate'
defaultMessage='Disable'
/>,
)).toEqual(false);
expect(wrapper.contains(
<FormattedMessage
id='user.settings.tokens.activate'
defaultMessage='Enable'
/>,
)).toEqual(true);
});
});

Просмотреть файл

@@ -0,0 +1,533 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ChangeEvent, SyntheticEvent, ReactNode} from 'react';
import {Link} from 'react-router-dom';
import {FormattedMessage} from 'react-intl';
import {ActionResult} from 'mattermost-redux/types/actions';
import {Bot as BotType} from '@mattermost/types/bots';
import {UserProfile, UserAccessToken} from '@mattermost/types/users';
import {Team} from '@mattermost/types/teams';
import ConfirmModal from 'components/confirm_modal';
import Markdown from 'components/markdown';
import SaveButton from 'components/save_button';
import WarningIcon from 'components/widgets/icons/fa_warning_icon';
import * as Utils from 'utils/utils';
export function matchesFilter(bot: BotType, filter?: string, owner?: UserProfile): boolean {
if (!filter) {
return true;
}
const username = bot.username || '';
const description = bot.description || '';
const displayName = bot.display_name || '';
let ownerUsername = 'plugin';
if (owner && owner.username) {
ownerUsername = owner.username;
}
return !(username.toLowerCase().indexOf(filter) === -1 &&
displayName.toLowerCase().indexOf(filter) === -1 &&
description.toLowerCase().indexOf(filter) === -1 &&
ownerUsername.toLowerCase().indexOf(filter) === -1);
}
type Props = {
/**
* Bot that we are displaying
*/
bot: BotType;
/**
* Owner of the bot we are displaying
*/
owner?: UserProfile;
/**
* User of the bot we are displaying
*/
user: UserProfile;
/**
* The access tokens of the bot user
*/
accessTokens: Record<string, UserAccessToken>;
/**
* String used for filtering bot items
*/
filter?: string;
/**
* Determine whether this bot is managed by the app framework
*/
fromApp: boolean;
actions: {
/**
* Disable a bot
*/
disableBot: (userId: string) => Promise<ActionResult>;
/**
* Enable a bot
*/
enableBot: (userId: string) => Promise<ActionResult>;
/**
* Access token managment
*/
createUserAccessToken: (userId: string, description: string) => Promise<{
data: {token: string; description: string; id: string; is_active: boolean} | null;
error?: Error;
}>;
revokeUserAccessToken: (tokenId: string) => Promise<{data: string; error?: Error}>;
enableUserAccessToken: (tokenId: string) => Promise<{data: string; error?: Error}>;
disableUserAccessToken: (tokenId: string) => Promise<{data: string; error?: Error}>;
};
/**
* Only used for routing since backstage is team based.
*/
team: Team;
}
type State = {
confirmingId: string;
creatingTokenState: string;
token: UserAccessToken | Record<string, any>;
error: ReactNode;
}
export default class Bot extends React.PureComponent<Props, State> {
public constructor(props: Props) {
super(props);
this.state = {
confirmingId: '',
creatingTokenState: 'CLOSED',
token: {},
error: '',
};
}
enableBot = (): void => {
this.props.actions.enableBot(this.props.bot.user_id);
}
disableBot = (): void => {
this.props.actions.disableBot(this.props.bot.user_id);
}
enableUserAccessToken = (id: string): void => {
this.props.actions.enableUserAccessToken(id);
}
disableUserAccessToken = (id: string): void => {
this.props.actions.disableUserAccessToken(id);
}
confirmRevokeToken = (id: string): void => {
this.setState({confirmingId: id});
}
revokeTokenConfirmed = (): void => {
this.props.actions.revokeUserAccessToken(this.state.confirmingId);
this.closeConfirm();
}
closeConfirm = (): void => {
this.setState({confirmingId: ''});
}
openCreateToken = (): void => {
this.setState({
creatingTokenState: 'OPEN',
token: {
description: '',
},
});
}
closeCreateToken = (): void => {
this.setState({
creatingTokenState: 'CLOSED',
token: {
description: '',
},
});
}
handleUpdateDescription = (e: ChangeEvent<HTMLInputElement>): void => {
const target = e.target as HTMLInputElement;
this.setState({
token: Object.assign({}, this.state.token, {description: target.value}),
});
}
handleCreateToken = async (e: SyntheticEvent): Promise<void> => {
e.preventDefault();
if (this.state.token.description === '') {
this.setState({error: (
<FormattedMessage
id='bot.token.error.description'
defaultMessage='Please enter a description.'
/>
)});
return;
}
const {data, error} = await this.props.actions.createUserAccessToken(this.props.bot.user_id, this.state.token.description);
if (data) {
this.setState({creatingTokenState: 'CREATED', token: data});
} else if (error) {
this.setState({error: error.message});
}
}
public render(): JSX.Element | null {
const username = this.props.bot.username || '';
const description = this.props.bot.description || '';
const displayName = this.props.bot.display_name || '';
let ownerUsername = 'plugin';
if (this.props.fromApp) {
ownerUsername = 'Apps Framework';
} else if (this.props.owner && this.props.owner.username) {
ownerUsername = this.props.owner.username;
}
const filter = this.props.filter ? this.props.filter.toLowerCase() : '';
if (!matchesFilter(this.props.bot, filter, this.props.owner)) {
return null;
}
const tokenList = [];
Object.values(this.props.accessTokens).forEach((token) => {
let activeLink;
let disableClass = '';
let disabledText;
if (token.is_active) {
activeLink = (
<a
id={token.id + '_deactivate'}
href='#'
onClick={(e) => {
e.preventDefault();
this.disableUserAccessToken(token.id);
}}
>
<FormattedMessage
id='user.settings.tokens.deactivate'
defaultMessage='Disable'
/>
</a>);
} else {
disableClass = 'light';
disabledText = (
<span className='mr-2 light'>
<FormattedMessage
id='user.settings.tokens.deactivatedWarning'
defaultMessage='(Disabled)'
/>
</span>
);
activeLink = (
<a
id={token.id + '_activate'}
href='#'
onClick={(e) => {
e.preventDefault();
this.enableUserAccessToken(token.id);
}}
>
<FormattedMessage
id='user.settings.tokens.activate'
defaultMessage='Enable'
/>
</a>
);
}
tokenList.push(
<div
key={token.id}
className='bot-list__item'
>
<div className='item-details__row d-flex justify-content-between'>
<div className={disableClass}>
<div className='whitespace--nowrap overflow--ellipsis'>
<b>
<FormattedMessage
id='user.settings.tokens.tokenDesc'
defaultMessage='Token Description: '
/>
</b>
{token.description}
</div>
<div className='setting-box__token-id whitespace--nowrap overflow--ellipsis'>
<b>
<FormattedMessage
id='user.settings.tokens.tokenId'
defaultMessage='Token ID: '
/>
</b>
{token.id}
</div>
</div>
<div>
{disabledText}
{activeLink}
{' - '}
<a
id={token.id + '_delete'}
href='#'
onClick={(e) => {
e.preventDefault();
this.confirmRevokeToken(token.id);
}}
>
<FormattedMessage
id='user.settings.tokens.delete'
defaultMessage='Delete'
/>
</a>
</div>
</div>
</div>,
);
});
let options;
if (ownerUsername !== 'plugin') {
options = (
<div className='item-actions'>
<button
id='createToken'
className='style--none color--link'
onClick={this.openCreateToken}
>
<FormattedMessage
id='bot.manage.create_token'
defaultMessage='Create New Token'
/>
</button>
{' - '}
<Link to={`/${this.props.team.name}/integrations/bots/edit?id=${this.props.bot.user_id}`}>
<FormattedMessage
id='bots.manage.edit'
defaultMessage='Edit'
/>
</Link>
{' - '}
<button
className='style--none color--link'
onClick={this.disableBot}
>
<FormattedMessage
id='bot.manage.disable'
defaultMessage='Disable'
/>
</button>
</div>
);
}
if (this.props.bot.delete_at !== 0) {
options = (
<div className='item-actions'>
<button
className='style--none color--link'
onClick={this.enableBot}
>
<FormattedMessage
id='bot.manage.enable'
defaultMessage='Enable'
/>
</button>
</div>
);
}
if (this.state.creatingTokenState === 'OPEN') {
tokenList.push(
<div
key={'create'}
className='bot-list__item'
>
<div key={'create'}>
<form
className='form-horizontal'
onSubmit={this.handleCreateToken}
>
<div className='row'>
<label className='col-sm-auto control-label'>
<FormattedMessage
id='user.settings.tokens.name'
defaultMessage='Token Description: '
/>
</label>
<div className='col-sm-4'>
<input
autoFocus={true}
className='form-control form-sm'
type='text'
maxLength={64}
value={this.state.token.description}
onChange={this.handleUpdateDescription}
/>
</div>
</div>
<div>
<div className='pt-2 pb-2'>
<FormattedMessage
id='user.settings.tokens.nameHelp'
defaultMessage='Enter a description for your token to remember what it does.'
/>
</div>
<label
id='clientError'
className='has-error is-empty'
>
{this.state.error}
</label>
<div className='mt-2'>
<SaveButton
btnClass='btn-sm btn-primary'
savingMessage={
<FormattedMessage
id='user.settings.tokens.save'
defaultMessage='Save'
/>
}
saving={false}
/>
<button
className='btn btn-sm btn-link'
onClick={this.closeCreateToken}
>
<FormattedMessage
id='user.settings.tokens.cancel'
defaultMessage='Cancel'
/>
</button>
</div>
</div>
</form>
</div>
</div>,
);
} else if (this.state.creatingTokenState === 'CREATED') {
tokenList.push(
<div
key={'created'}
className='bot-list__item alert alert-warning'
>
<div className='mb-2'>
<WarningIcon additionalClassName='mr-2'/>
<FormattedMessage
id='user.settings.tokens.copy'
defaultMessage="Please copy the access token below. You won't be able to see it again!"
/>
</div>
<div className='whitespace--nowrap overflow--ellipsis'>
<FormattedMessage
id='user.settings.tokens.name'
defaultMessage='Token Description: '
/>
{this.state.token.description}
</div>
<div className='whitespace--nowrap overflow--ellipsis'>
<FormattedMessage
id='user.settings.tokens.id'
defaultMessage='Token ID: '
/>
{this.state.token.id}
</div>
<strong className='word-break--all'>
<FormattedMessage
id='user.settings.tokens.token'
defaultMessage='Access Token: '
/>
{this.state.token.token}
</strong>
<div className='mt-2'>
<button
className='btn btn-sm btn-primary'
onClick={this.closeCreateToken}
>
<FormattedMessage
id='bot.create_token.close'
defaultMessage='Close'
/>
</button>
</div>
</div>,
);
}
const imageURL = Utils.imageURLForUser(this.props.user.id, this.props.user.last_picture_update);
return (
<div className='backstage-list__item'>
<div className={'bot-list-img-container'}>
<img
className={'bot-list-img'}
alt={'bot image'}
src={imageURL}
/>
</div>
<div className='item-details'>
<div className='item-details__row d-flex flex-column flex-md-row justify-content-between'>
<strong className='item-details__name'>
{displayName + ' (@' + username + ')'}
</strong>
{options}
</div>
<div className='bot-details__description'>
<Markdown message={description}/>
</div>
<div className='light small'>
<FormattedMessage
id='bots.managed_by'
defaultMessage='Managed by '
/>
{ownerUsername}
</div>
<div className='bot-list is-empty'>
{tokenList}
</div>
</div>
<ConfirmModal
title={
<FormattedMessage
id='bots.token.delete'
defaultMessage='Delete Token'
/>
}
message={
<FormattedMessage
id='bots.token.confirm_text'
defaultMessage='Are you sure you want to delete the token?'
/>
}
confirmButtonText={
<FormattedMessage
id='bots.token.confirm'
defaultMessage='Delete'
/>
}
show={this.state.confirmingId !== ''}
onConfirm={this.revokeTokenConfirmed}
onCancel={this.closeConfirm}
/>
</div>
);
}
}

Просмотреть файл

@@ -0,0 +1,217 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {TestHelper} from 'utils/test_helper';
import Bot from './bot';
import Bots from './bots';
describe('components/integrations/bots/Bots', () => {
const team = TestHelper.getTeamMock();
const actions = {
loadBots: jest.fn().mockReturnValue(Promise.resolve({})),
getUserAccessTokensForUser: jest.fn(),
createUserAccessToken: jest.fn(),
revokeUserAccessToken: jest.fn(),
enableUserAccessToken: jest.fn(),
disableUserAccessToken: jest.fn(),
getUser: jest.fn(),
disableBot: jest.fn(),
enableBot: jest.fn(),
fetchAppsBotIDs: jest.fn(),
};
it('bots', () => {
const bot1 = TestHelper.getBotMock({user_id: '1'});
const bot2 = TestHelper.getBotMock({user_id: '2'});
const bot3 = TestHelper.getBotMock({user_id: '3'});
const bots = {
[bot1.user_id]: bot1,
[bot2.user_id]: bot2,
[bot3.user_id]: bot3,
};
const users = {
[bot1.user_id]: TestHelper.getUserMock({id: bot1.user_id}),
[bot2.user_id]: TestHelper.getUserMock({id: bot2.user_id}),
[bot3.user_id]: TestHelper.getUserMock({id: bot3.user_id}),
};
const wrapperFull = shallow(
<Bots
bots={bots}
team={team}
accessTokens={{}}
owners={{}}
users={users}
actions={actions}
appsEnabled={false}
appsBotIDs={[]}
/>,
);
wrapperFull.instance().setState({loading: false});
const wrapper = shallow(<div>{(wrapperFull.instance() as Bots).bots()[0]}</div>);
expect(wrapper.find('EnabledSection').shallow().contains(
<Bot
key={bot1.user_id}
bot={bot1}
owner={undefined}
user={users[bot1.user_id]}
accessTokens={{}}
team={team}
actions={actions}
fromApp={false}
/>,
)).toEqual(true);
expect(wrapper.find('EnabledSection').shallow().contains(
<Bot
key={bot2.user_id}
bot={bot2}
owner={undefined}
user={users[bot2.user_id]}
accessTokens={{}}
team={team}
actions={actions}
fromApp={false}
/>,
)).toEqual(true);
expect(wrapper.find('EnabledSection').shallow().contains(
<Bot
key={bot3.user_id}
bot={bot3}
owner={undefined}
user={users[bot3.user_id]}
accessTokens={{}}
team={team}
actions={actions}
fromApp={false}
/>,
)).toEqual(true);
});
it('bots with bots from apps', () => {
const bot1 = TestHelper.getBotMock({user_id: '1'});
const bot2 = TestHelper.getBotMock({user_id: '2'});
const bot3 = TestHelper.getBotMock({user_id: '3'});
const bots = {
[bot1.user_id]: bot1,
[bot2.user_id]: bot2,
[bot3.user_id]: bot3,
};
const users = {
[bot1.user_id]: TestHelper.getUserMock({id: bot1.user_id}),
[bot2.user_id]: TestHelper.getUserMock({id: bot2.user_id}),
[bot3.user_id]: TestHelper.getUserMock({id: bot3.user_id}),
};
const wrapperFull = shallow(
<Bots
bots={bots}
team={team}
accessTokens={{}}
owners={{}}
users={users}
actions={actions}
appsEnabled={true}
appsBotIDs={[bot3.user_id]}
/>,
);
wrapperFull.instance().setState({loading: false});
const wrapper = shallow(<div>{(wrapperFull.instance() as Bots).bots()[0]}</div>);
expect(wrapper.find('EnabledSection').shallow().contains(
<Bot
key={bot1.user_id}
bot={bot1}
owner={undefined}
user={users[bot1.user_id]}
accessTokens={{}}
team={team}
actions={actions}
fromApp={false}
/>,
)).toEqual(true);
expect(wrapper.find('EnabledSection').shallow().contains(
<Bot
key={bot2.user_id}
bot={bot2}
owner={undefined}
user={users[bot2.user_id]}
accessTokens={{}}
team={team}
actions={actions}
fromApp={false}
/>,
)).toEqual(true);
expect(wrapper.find('EnabledSection').shallow().contains(
<Bot
key={bot3.user_id}
bot={bot3}
owner={undefined}
user={users[bot3.user_id]}
accessTokens={{}}
team={team}
actions={actions}
fromApp={true}
/>,
)).toEqual(true);
});
it('bot owner tokens', () => {
const bot1 = TestHelper.getBotMock({user_id: '1', owner_id: '1'});
const bots = {
[bot1.user_id]: bot1,
};
const owner = TestHelper.getUserMock({id: bot1.owner_id});
const user = TestHelper.getUserMock({id: bot1.user_id});
const passedTokens = {
id: TestHelper.getUserAccessTokenMock(),
};
const owners = {
[bot1.user_id]: owner,
};
const users = {
[bot1.user_id]: user,
};
const tokens = {
[bot1.user_id]: passedTokens,
};
const wrapperFull = shallow(
<Bots
bots={bots}
team={team}
accessTokens={tokens}
owners={owners}
users={users}
actions={actions}
appsEnabled={false}
appsBotIDs={[]}
/>,
);
wrapperFull.instance().setState({loading: false});
const wrapper = shallow(<div>{(wrapperFull.instance() as Bots).bots()[0]}</div>);
expect(wrapper.find('EnabledSection').shallow().contains(
<Bot
key={bot1.user_id}
bot={bot1}
owner={owner}
user={user}
accessTokens={passedTokens}
team={team}
actions={actions}
fromApp={false}
/>,
)).toEqual(true);
});
});

Просмотреть файл

@@ -0,0 +1,282 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {Bot as BotType} from '@mattermost/types/bots';
import {RelationOneToOne} from '@mattermost/types/utilities';
import {UserProfile, UserAccessToken} from '@mattermost/types/users';
import {ActionResult} from 'mattermost-redux/types/actions';
import {Team} from '@mattermost/types/teams';
import {getSiteURL} from 'utils/url';
import * as Utils from 'utils/utils';
import Constants from 'utils/constants';
import BackstageList from 'components/backstage/components/backstage_list';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import ExternalLink from 'components/external_link';
import Bot, {matchesFilter} from './bot';
type Props = {
/**
* Map from botUserId to bot.
*/
bots: Record<string, BotType>;
/**
* List of bot IDs managed by the app framework
*/
appsBotIDs: string[];
/**
* Whether apps framework is enabled
*/
appsEnabled: boolean;
/**
* Map from botUserId to accessTokens.
*/
accessTokens?: RelationOneToOne<UserProfile, Record<string, UserAccessToken>>;
/**
* Map from botUserId to owner.
*/
owners: Record<string, UserProfile>;
/**
* Map from botUserId to user.
*/
users: Record<string, UserProfile>;
createBots?: boolean;
actions: {
/**
* Ensure we have bot accounts
*/
loadBots: (page?: number, perPage?: number) => Promise<{data: BotType[]; error?: Error}>;
/**
* Load access tokens for bot accounts
*/
getUserAccessTokensForUser: (userId: string, page?: number, perPage?: number) => void;
/**
* Access token managment
*/
createUserAccessToken: (userId: string, description: string) => Promise<{
data: {token: string; description: string; id: string; is_active: boolean} | null;
error?: Error;
}>;
revokeUserAccessToken: (tokenId: string) => Promise<{data: string; error?: Error}>;
enableUserAccessToken: (tokenId: string) => Promise<{data: string; error?: Error}>;
disableUserAccessToken: (tokenId: string) => Promise<{data: string; error?: Error}>;
/**
* Load owner of bot account
*/
getUser: (userId: string) => void;
/**
* Disable a bot
*/
disableBot: (userId: string) => Promise<ActionResult>;
/**
* Enable a bot
*/
enableBot: (userId: string) => Promise<ActionResult>;
/**
* Load bot IDs managed by the apps
*/
fetchAppsBotIDs: () => Promise<ActionResult>;
};
/**
* Only used for routing since backstage is team based.
*/
team: Team;
}
type State = {
loading: boolean;
}
export default class Bots extends React.PureComponent<Props, State> {
public constructor(props: Props) {
super(props);
this.state = {
loading: true,
};
}
public componentDidMount(): void {
this.props.actions.loadBots(
Constants.Integrations.START_PAGE_NUM,
parseInt(Constants.Integrations.PAGE_SIZE, 10),
).then(
(result) => {
if (result.data) {
const promises = [];
for (const bot of result.data) {
// We don't need to wait for this and we need to accept failure in the case where bot.owner_id is a plugin id
this.props.actions.getUser(bot.owner_id);
// We want to wait for these.
promises.push(this.props.actions.getUser(bot.user_id));
promises.push(this.props.actions.getUserAccessTokensForUser(bot.user_id));
}
Promise.all(promises).then(() => {
this.setState({loading: false});
});
}
},
);
if (this.props.appsEnabled) {
this.props.actions.fetchAppsBotIDs();
}
}
DisabledSection(props: {hasDisabled: boolean; disabledBots: JSX.Element[]; filter?: string}): JSX.Element | null {
if (!props.hasDisabled) {
return null;
}
const botsToDisplay = React.Children.map(props.disabledBots, (child) => {
return React.cloneElement(child, {filter: props.filter});
});
return (
<React.Fragment>
<div className='bot-disabled'>
<FormattedMessage
id='bots.disabled'
defaultMessage='Disabled'
/>
</div>
<div className='bot-list__disabled'>
{botsToDisplay}
</div>
</React.Fragment>
);
}
EnabledSection(props: {enabledBots: JSX.Element[]; filter?: string}): JSX.Element {
const botsToDisplay = React.Children.map(props.enabledBots, (child) => {
return React.cloneElement(child, {filter: props.filter});
});
return (
<div>
{botsToDisplay}
</div>
);
}
botToJSX = (bot: BotType): JSX.Element => {
return (
<Bot
key={bot.user_id}
bot={bot}
owner={this.props.owners[bot.user_id]}
user={this.props.users[bot.user_id]}
accessTokens={(this.props.accessTokens && this.props.accessTokens[bot.user_id]) || {}}
actions={this.props.actions}
team={this.props.team}
fromApp={this.props.appsBotIDs.includes(bot.user_id)}
/>
);
};
bots = (filter?: string): Array<boolean | JSX.Element> => {
const bots = Object.values(this.props.bots).sort((a, b) => a.username.localeCompare(b.username));
const match = (bot: BotType) => matchesFilter(bot, filter, this.props.owners[bot.user_id]);
const enabledBots = bots.filter((bot) => bot.delete_at === 0).filter(match).map(this.botToJSX);
const disabledBots = bots.filter((bot) => bot.delete_at > 0).filter(match).map(this.botToJSX);
const sections = (
<div key='sections'>
<this.EnabledSection
enabledBots={enabledBots}
/>
<this.DisabledSection
hasDisabled={disabledBots.length > 0}
disabledBots={disabledBots}
/>
</div>
);
return [sections, enabledBots.length > 0 || disabledBots.length > 0];
}
public render(): JSX.Element {
return (
<BackstageList
header={
<FormattedMessage
id='bots.manage.header'
defaultMessage='Bot Accounts'
/>
}
addText={this.props.createBots &&
<FormattedMessage
id='bots.manage.add'
defaultMessage='Add Bot Account'
/>
}
addLink={'/' + this.props.team.name + '/integrations/bots/add'}
addButtonId='addBotAccount'
emptyText={
<FormattedMessage
id='bots.manage.empty'
defaultMessage='No bot accounts found'
/>
}
emptyTextSearch={
<FormattedMarkdownMessage
id='bots.manage.emptySearch'
defaultMessage='No bot accounts match **{searchTerm}**'
/>
}
helpText={
<React.Fragment>
<FormattedMessage
id='bots.manage.help1'
defaultMessage='Use {botAccounts} to integrate with Mattermost through plugins or the API. Bot accounts are available to everyone on your server. '
values={{
botAccounts: (
<ExternalLink
href='https://mattermost.com/pl/default-bot-accounts'
location='bots'
>
<FormattedMessage
id='bots.manage.bot_accounts'
defaultMessage='Bot Accounts'
/>
</ExternalLink>
),
}}
/>
<FormattedMarkdownMessage
id='bots.manage.help2'
defaultMessage={'Enable bot account creation in the [System Console]({siteURL}/admin_console/integrations/bot_accounts).'}
values={{
siteURL: getSiteURL(),
}}
/>
</React.Fragment>
}
searchPlaceholder={Utils.localizeMessage('bots.manage.search', 'Search Bot Accounts')}
loading={this.state.loading}
>
{this.bots}
</BackstageList>
);
}
}

Просмотреть файл

@@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux';
import {getExternalBotAccounts} from 'mattermost-redux/selectors/entities/bots';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {loadBots, disableBot, enableBot} from 'mattermost-redux/actions/bots';
import {getAppsBotIDs as fetchAppsBotIDs} from 'mattermost-redux/actions/integrations';
import {getAppsBotIDs} from 'mattermost-redux/selectors/entities/integrations';
import {createUserAccessToken, revokeUserAccessToken, enableUserAccessToken, disableUserAccessToken, getUserAccessTokensForUser, getUser} from 'mattermost-redux/actions/users';
import * as UserSelectors from 'mattermost-redux/selectors/entities/users';
import {GlobalState} from '@mattermost/types/store';
import {GenericAction, ActionResult, ActionFunc} from 'mattermost-redux/types/actions';
import {Bot as BotType} from '@mattermost/types/bots';
import {UserProfile} from '@mattermost/types/users';
import {appsEnabled} from 'mattermost-redux/selectors/entities/apps';
import Bots from './bots';
function mapStateToProps(state: GlobalState) {
const config = getConfig(state);
const createBots = config.EnableBotAccountCreation === 'true';
const bots = getExternalBotAccounts(state);
const botValues = Object.values(bots);
const owners = botValues.
reduce((result: Record<string, UserProfile>, bot: BotType) => {
result[bot.user_id] = UserSelectors.getUser(state, bot.owner_id);
return result;
}, {});
const users = botValues.
reduce((result: Record<string, UserProfile>, bot: BotType) => {
result[bot.user_id] = UserSelectors.getUser(state, bot.user_id);
return result;
}, {});
return {
createBots,
bots,
accessTokens: state.entities.admin.userAccessTokensByUser,
owners,
users,
appsBotIDs: getAppsBotIDs(state),
appsEnabled: appsEnabled(state),
};
}
type Actions = {
fetchAppsBotIDs: () => Promise<{data: string[]}>;
loadBots: (page?: number, perPage?: number) => Promise<{data: BotType[]; error?: Error}>;
getUserAccessTokensForUser: (userId: string, page?: number, perPage?: number) => void;
createUserAccessToken: (userId: string, description: string) => Promise<{
data: {token: string; description: string; id: string; is_active: boolean} | null;
error?: Error;
}>;
revokeUserAccessToken: (tokenId: string) => Promise<{data: string; error?: Error}>;
enableUserAccessToken: (tokenId: string) => Promise<{data: string; error?: Error}>;
disableUserAccessToken: (tokenId: string) => Promise<{data: string; error?: Error}>;
getUser: (userId: string) => void;
disableBot: (userId: string) => Promise<ActionResult>;
enableBot: (userId: string) => Promise<ActionResult>;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
fetchAppsBotIDs,
loadBots,
getUserAccessTokensForUser,
createUserAccessToken,
revokeUserAccessToken,
enableUserAccessToken,
disableUserAccessToken,
getUser,
disableBot,
enableBot,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(Bots);

Просмотреть файл

@@ -0,0 +1,147 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Route, Switch, Redirect} from 'react-router-dom';
import {Team} from '@mattermost/types/teams';
import {UserProfile} from '@mattermost/types/users';
import {RelationOneToOne} from '@mattermost/types/utilities';
import {Command} from '@mattermost/types/integrations';
import InstalledCommands from 'components/integrations/installed_commands';
import AddCommand from 'components/integrations/add_command';
import EditCommand from 'components/integrations/edit_command';
import ConfirmIntegration from 'components/integrations/confirm_integration';
interface IProps {
component: any;
extraProps: {
loading: boolean;
commands: Command[];
users?: RelationOneToOne<UserProfile, UserProfile>;
team?: Team;
user?: UserProfile;
};
path: string;
}
const CommandRoute = ({component: Component, extraProps, ...rest}: IProps) => (
<Route
{...rest}
render={(props) => (
<Component
{...extraProps}
{...props}
/>
)}
/>
);
type Props = {
/**
* The team data needed to pass into child components
*/
team?: Team;
/**
* The user data needed to pass into child components
*/
user?: UserProfile;
/**
* The users collection
*/
users?: RelationOneToOne<UserProfile, UserProfile>;
/**
* Installed slash commands to display
*/
commands: Command[];
/**
* Object from react-router
*/
match: {
url: string;
};
actions: {
/**
* The function to call to fetch team commands
*/
loadCommandsAndProfilesForTeam: (teamId?: string) => any; // TechDebt-TODO: This needs to be changed to 'Promise<void>'
};
/**
* Whether or not commands are enabled.
*/
enableCommands?: boolean;
};
type State = {
loading: boolean;
};
export default class CommandsContainer extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loading: true,
};
}
componentDidMount() {
if (this.props.enableCommands) {
this.props.actions.loadCommandsAndProfilesForTeam(this.props.team?.id).then(
() => this.setState({loading: false}),
);
}
}
render() {
const extraProps = {
loading: this.state.loading,
commands: this.props.commands || [],
users: this.props.users,
team: this.props.team,
user: this.props.user,
};
return (
<div>
<Switch>
<Route
exact={true}
path={`${this.props.match.url}/`}
render={() => (<Redirect to={`${this.props.match.url}/installed`}/>)}
/>
<CommandRoute
extraProps={extraProps}
path={`${this.props.match.url}/installed`}
component={InstalledCommands}
/>
<CommandRoute
extraProps={extraProps}
path={`${this.props.match.url}/add`}
component={AddCommand}
/>
<CommandRoute
extraProps={extraProps}
path={`${this.props.match.url}/edit`}
component={EditCommand}
/>
<CommandRoute
extraProps={extraProps}
path={`${this.props.match.url}/confirm`}
component={ConfirmIntegration}
/>
</Switch>
</div>
);
}
}

Просмотреть файл

@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {AnyAction, bindActionCreators, Dispatch} from 'redux';
import {getCommands} from 'mattermost-redux/selectors/entities/integrations';
import {getUsers} from 'mattermost-redux/selectors/entities/users';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {GlobalState} from '@mattermost/types/store';
import {loadCommandsAndProfilesForTeam} from 'actions/integration_actions';
import CommandsContainer from './commands_container';
function mapStateToProps(state: GlobalState) {
const config = getConfig(state);
const enableCommands = config.EnableCommands === 'true';
return {
commands: Object.values(getCommands(state)),
users: getUsers(state),
enableCommands,
};
}
function mapDispatchToProps(dispatch: Dispatch<AnyAction>) {
return {
actions: bindActionCreators({
loadCommandsAndProfilesForTeam,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(CommandsContainer);

Просмотреть файл

@@ -0,0 +1,355 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/ConfirmIntegration should match snapshot, bad integration type case 1`] = `""`;
exports[`components/integrations/ConfirmIntegration should match snapshot, commands case 1`] = `
<div
className="backstage-content row"
>
<BackstageHeader>
<Link
to="/team_test/integrations/commands"
>
<MemoizedFormattedMessage
defaultMessage="Slash Commands"
id="installed_commands.header"
/>
</Link>
<MemoizedFormattedMessage
defaultMessage="Add"
id="integrations.add"
/>
</BackstageHeader>
<div
className="backstage-form backstage-form__confirmation"
>
<h4
className="backstage-form__title"
id="formTitle"
>
<MemoizedFormattedMessage
defaultMessage="Setup Successful"
id="integrations.successful"
/>
</h4>
<p>
<MemoizedFormattedMessage
defaultMessage="Your slash command is set up. The following token will be sent in the outgoing payload. Please use it to verify the request came from your Mattermost team (details at <link>Slash Commands</link>)."
id="add_command.doneHelp"
values={
Object {
"link": [Function],
}
}
/>
</p>
<p
className="word-break--all"
>
<FormattedMarkdownMessage
defaultMessage="**Token**: {token}"
id="add_command.token"
values={
Object {
"token": "jb6oyqh95irpbx8fo9zmndkp1r",
}
}
/>
<CopyText
defaultMessage="Copy"
idMessage="integrations.copy"
value="jb6oyqh95irpbx8fo9zmndkp1r"
/>
</p>
<div
className="backstage-form__footer"
>
<Link
className="btn btn-primary"
id="doneButton"
to="/team_test/integrations/commands"
type="submit"
>
<MemoizedFormattedMessage
defaultMessage="Done"
id="integrations.done"
/>
</Link>
</div>
</div>
</div>
`;
exports[`components/integrations/ConfirmIntegration should match snapshot, incomingHooks case 1`] = `
<div
className="backstage-content row"
>
<BackstageHeader>
<Link
to="/team_test/integrations/incoming_webhooks"
>
<MemoizedFormattedMessage
defaultMessage="Incoming Webhooks"
id="installed_incoming_webhooks.header"
/>
</Link>
<MemoizedFormattedMessage
defaultMessage="Add"
id="integrations.add"
/>
</BackstageHeader>
<div
className="backstage-form backstage-form__confirmation"
>
<h4
className="backstage-form__title"
id="formTitle"
>
<MemoizedFormattedMessage
defaultMessage="Setup Successful"
id="integrations.successful"
/>
</h4>
<p>
<MemoizedFormattedMessage
defaultMessage="Your incoming webhook is set up. Please send data to the following URL (details at <link>Incoming Webhooks</link>)."
id="add_incoming_webhook.doneHelp"
values={
Object {
"link": [Function],
}
}
/>
</p>
<p
className="word-break--all"
>
<FormattedMarkdownMessage
defaultMessage="**URL**: {url}"
id="add_incoming_webhook.url"
values={
Object {
"url": "\`http://localhost:8065/hooks/r5tpgt4iepf45jt768jz84djic\`",
}
}
/>
<CopyText
defaultMessage="Copy"
idMessage="integrations.copy"
value="http://localhost:8065/hooks/r5tpgt4iepf45jt768jz84djic"
/>
</p>
<div
className="backstage-form__footer"
>
<Link
className="btn btn-primary"
id="doneButton"
to="/team_test/integrations/incoming_webhooks"
type="submit"
>
<MemoizedFormattedMessage
defaultMessage="Done"
id="integrations.done"
/>
</Link>
</div>
</div>
</div>
`;
exports[`components/integrations/ConfirmIntegration should match snapshot, oauthApps case 1`] = `
<div
className="backstage-content row"
>
<BackstageHeader>
<Link
to="/team_test/integrations/oauth2-apps"
>
<MemoizedFormattedMessage
defaultMessage="OAuth 2.0 Applications"
id="installed_oauth_apps.header"
/>
</Link>
<MemoizedFormattedMessage
defaultMessage="Add"
id="integrations.add"
/>
</BackstageHeader>
<div
className="backstage-form backstage-form__confirmation"
>
<h4
className="backstage-form__title"
id="formTitle"
>
<MemoizedFormattedMessage
defaultMessage="Setup Successful"
id="integrations.successful"
/>
</h4>
<p
key="add_oauth_app.doneHelp"
>
<MemoizedFormattedMessage
defaultMessage="Your OAuth 2.0 application is set up. Please use the following Client ID and Client Secret when requesting authorization for your application (details at <link>oAuth 2 Applications</link>)."
id="add_oauth_app.doneHelp"
values={
Object {
"link": [Function],
}
}
/>
</p>
<p
key="add_oauth_app.clientId"
>
<FormattedMarkdownMessage
defaultMessage="**Client ID**: {id}"
id="add_oauth_app.clientId"
values={
Object {
"id": "r5tpgt4iepf45jt768jz84djic",
}
}
/>
<CopyText
defaultMessage="Copy Client Id"
idMessage="integrations.copy_client_id"
value="r5tpgt4iepf45jt768jz84djic"
/>
<br />
<FormattedMarkdownMessage
defaultMessage="**Client Secret**: {secret}"
id="add_oauth_app.clientSecret"
values={
Object {
"secret": "<==secret==>",
}
}
/>
<CopyText
defaultMessage="Copy Client Secret"
idMessage="integrations.copy_client_secret"
value="<==secret==>"
/>
</p>
<p
key="add_oauth_app.doneUrlHelp"
>
<MemoizedFormattedMessage
defaultMessage="Here are your authorized redirect URLs."
id="add_oauth_app.doneUrlHelp"
/>
</p>
<p
className="word-break--all"
>
<FormattedMarkdownMessage
defaultMessage="**URL(s)**: {url}"
id="add_oauth_app.url"
values={
Object {
"url": "https://someCallback, https://anotherCallback",
}
}
/>
</p>
<div
className="backstage-form__footer"
>
<Link
className="btn btn-primary"
id="doneButton"
to="/team_test/integrations/oauth2-apps"
type="submit"
>
<MemoizedFormattedMessage
defaultMessage="Done"
id="integrations.done"
/>
</Link>
</div>
</div>
</div>
`;
exports[`components/integrations/ConfirmIntegration should match snapshot, outgoingHooks and bad identifier case 1`] = `""`;
exports[`components/integrations/ConfirmIntegration should match snapshot, outgoingHooks case 1`] = `
<div
className="backstage-content row"
>
<BackstageHeader>
<Link
to="/team_test/integrations/outgoing_webhooks"
>
<MemoizedFormattedMessage
defaultMessage="Outgoing Webhooks"
id="installed_outgoing_webhooks.header"
/>
</Link>
<MemoizedFormattedMessage
defaultMessage="Add"
id="integrations.add"
/>
</BackstageHeader>
<div
className="backstage-form backstage-form__confirmation"
>
<h4
className="backstage-form__title"
id="formTitle"
>
<MemoizedFormattedMessage
defaultMessage="Setup Successful"
id="integrations.successful"
/>
</h4>
<p>
<MemoizedFormattedMessage
defaultMessage="Your outgoing webhook is set up. The following token will be sent in the outgoing payload. Please use it to verify that the request came from your Mattermost team (details at <link>Outgoing Webhooks</link>)."
id="add_outgoing_webhook.doneHelp"
values={
Object {
"link": [Function],
}
}
/>
</p>
<p
className="word-break--all"
>
<FormattedMarkdownMessage
defaultMessage="**Token**: {token}"
id="add_outgoing_webhook.token"
values={
Object {
"token": "jb6oyqh95irpbx8fo9zmndkp1r",
}
}
/>
<CopyText
defaultMessage="Copy"
idMessage="integrations.copy"
value="jb6oyqh95irpbx8fo9zmndkp1r"
/>
</p>
<div
className="backstage-form__footer"
>
<Link
className="btn btn-primary"
id="doneButton"
to="/team_test/integrations/outgoing_webhooks"
type="submit"
>
<MemoizedFormattedMessage
defaultMessage="Done"
id="integrations.done"
/>
</Link>
</div>
</div>
</div>
`;

Просмотреть файл

@@ -0,0 +1,128 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {Router} from 'react-router-dom';
import {getHistory} from 'utils/browser_history';
import {TestHelper} from 'utils/test_helper';
import {renderWithIntlAndStore} from 'tests/react_testing_utils';
import ConfirmIntegration from 'components/integrations/confirm_integration/confirm_integration';
import {IncomingWebhook, OAuthApp, OutgoingWebhook} from '@mattermost/types/integrations';
import {Bot} from '@mattermost/types/bots';
import {IDMappedObjects} from '@mattermost/types/utilities';
describe('components/integrations/ConfirmIntegration', () => {
const id = 'r5tpgt4iepf45jt768jz84djic';
const token = 'jb6oyqh95irpbx8fo9zmndkp1r';
const getSearchString = (type: string, identifier = id) => `?type=${type}&id=${identifier}`;
const initialState = {
entities: {
general: {
config: {},
license: {
Cloud: 'false',
},
},
users: {
currentUserId: 'currentUserId',
},
},
};
const location = {
search: '',
};
const team = TestHelper.getTeamMock({
name: 'team_test',
});
const oauthApp = {
id,
client_secret: '<==secret==>',
callback_urls: ['https://someCallback', 'https://anotherCallback'],
};
const userId = 'b5tpgt4iepf45jt768jz84djhd';
const bot = TestHelper.getBotMock({
user_id: userId,
display_name: 'bot',
});
const commands = {[id]: TestHelper.getCommandMock({id, token})};
const oauthApps = {[id]: oauthApp} as unknown as IDMappedObjects<OAuthApp>;
const incomingHooks: IDMappedObjects<IncomingWebhook> = {[id]: TestHelper.getIncomingWebhookMock({id})};
const outgoingHooks = {[id]: {id, token}} as unknown as IDMappedObjects<OutgoingWebhook>;
const bots: Record<string, Bot> = {[userId]: bot};
const props = {
team,
location,
commands,
oauthApps,
incomingHooks,
outgoingHooks,
bots,
};
test('should match snapshot, oauthApps case', () => {
props.location.search = getSearchString('oauth2-apps');
const wrapper = shallow(
<ConfirmIntegration {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match callback URLs of OAuth Apps', () => {
props.location.search = getSearchString('oauth2-apps');
const {container} = renderWithIntlAndStore(
<Router history={getHistory()}>
<ConfirmIntegration {...props}/>
</Router>,
initialState,
);
expect(container.querySelector('.word-break--all')).toHaveTextContent('URL(s): https://someCallback, https://anotherCallback');
});
test('should match snapshot, commands case', () => {
props.location.search = getSearchString('commands');
const wrapper = shallow(
<ConfirmIntegration {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, incomingHooks case', () => {
props.location.search = getSearchString('incoming_webhooks');
const wrapper = shallow(
<ConfirmIntegration {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, outgoingHooks case', () => {
props.location.search = getSearchString('outgoing_webhooks');
const wrapper = shallow(
<ConfirmIntegration {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, outgoingHooks and bad identifier case', () => {
props.location.search = getSearchString('outgoing_webhooks', 'bad');
const wrapper = shallow(
<ConfirmIntegration {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, bad integration type case', () => {
props.location.search = getSearchString('bad');
const wrapper = shallow(
<ConfirmIntegration {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@@ -0,0 +1,347 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react';
import {FormattedMessage} from 'react-intl';
import {Link, useHistory} from 'react-router-dom';
import {Constants, ErrorPageTypes} from 'utils/constants';
import {getSiteURL} from 'utils/url';
import {Team} from '@mattermost/types/teams';
import {Command, IncomingWebhook, OAuthApp, OutgoingWebhook} from '@mattermost/types/integrations';
import {IDMappedObjects} from '@mattermost/types/utilities';
import CopyText from 'components/copy_text';
import BackstageHeader from 'components/backstage/components/backstage_header';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import {Bot} from '@mattermost/types/bots';
import ExternalLink from 'components/external_link';
type Props = {
team: Team;
location: {search: string};
commands: IDMappedObjects<Command>;
oauthApps: IDMappedObjects<OAuthApp>;
incomingHooks: IDMappedObjects<IncomingWebhook>;
outgoingHooks: IDMappedObjects<OutgoingWebhook>;
bots: Record<string, Bot>;
}
const ConfirmIntegration = ({team, location, commands, oauthApps, incomingHooks, outgoingHooks, bots}: Props): JSX.Element | null => {
const history = useHistory();
const type = (new URLSearchParams(location.search)).get('type') || '';
const id = (new URLSearchParams(location.search)).get('id') || '';
useEffect(() => {
window.addEventListener('keypress', handleKeyPress);
return () => {
window.removeEventListener('keypress', handleKeyPress);
};
});
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
history.push('/' + team.name + '/integrations/' + type);
}
};
let headerText: JSX.Element;
let helpText: JSX.Element | JSX.Element[];
let tokenText: JSX.Element;
const command = commands[id];
const incomingHook = incomingHooks[id];
const outgoingHook = outgoingHooks[id];
const oauthApp = oauthApps[id];
const bot = bots[id];
if (type === Constants.Integrations.COMMAND && command) {
const commandToken = command.token;
headerText = (
<FormattedMessage
id={'installed_commands.header'}
defaultMessage='Slash Commands'
/>
);
helpText = (
<p>
<FormattedMessage
id='add_command.doneHelp'
defaultMessage='Your slash command is set up. The following token will be sent in the outgoing payload. Please use it to verify the request came from your Mattermost team (details at <link>Slash Commands</link>).'
values={{
link: (msg: string) => (
<ExternalLink
href='https://developers.mattermost.com/integrate/admin-guide/admin-slash-commands/'
location='confirm_integration'
>
{msg}
</ExternalLink>
),
}}
/>
</p>
);
tokenText = (
<p className='word-break--all'>
<FormattedMarkdownMessage
id='add_command.token'
defaultMessage='**Token**: {token}'
values={{token: commandToken}}
/>
<CopyText value={commandToken}/>
</p>
);
} else if (type === Constants.Integrations.INCOMING_WEBHOOK && incomingHook) {
const incomingHookToken = getSiteURL() + '/hooks/' + incomingHook.id;
headerText = (
<FormattedMessage
id={'installed_incoming_webhooks.header'}
defaultMessage='Incoming Webhooks'
/>
);
helpText = (
<p>
<FormattedMessage
id='add_incoming_webhook.doneHelp'
defaultMessage='Your incoming webhook is set up. Please send data to the following URL (details at <link>Incoming Webhooks</link>).'
values={{
link: (msg: string) => (
<ExternalLink
href='https://developers.mattermost.com/integrate/admin-guide/admin-webhooks-incoming/'
location='confirm_integration'
>
{msg}
</ExternalLink>
),
}}
/>
</p>
);
tokenText = (
<p className='word-break--all'>
<FormattedMarkdownMessage
id='add_incoming_webhook.url'
defaultMessage='**URL**: {url}'
values={{url: '`' + incomingHookToken + '`'}}
/>
<CopyText value={incomingHookToken}/>
</p>
);
} else if (type === Constants.Integrations.OUTGOING_WEBHOOK && outgoingHook) {
const outgoingHookToken = outgoingHook.token;
headerText = (
<FormattedMessage
id={'installed_outgoing_webhooks.header'}
defaultMessage='Outgoing Webhooks'
/>
);
helpText = (
<p>
<FormattedMessage
id='add_outgoing_webhook.doneHelp'
defaultMessage='Your outgoing webhook is set up. The following token will be sent in the outgoing payload. Please use it to verify that the request came from your Mattermost team (details at <link>Outgoing Webhooks</link>).'
values={{
link: (msg: string) => (
<ExternalLink
href='https://developers.mattermost.com/integrate/admin-guide/admin-webhooks-outgoing/'
location='confirm_integration'
>
{msg}
</ExternalLink>
),
}}
/>
</p>
);
tokenText = (
<p className='word-break--all'>
<FormattedMarkdownMessage
id='add_outgoing_webhook.token'
defaultMessage='**Token**: {token}'
values={{token: outgoingHookToken}}
/>
<CopyText value={outgoingHookToken}/>
</p>
);
} else if (type === Constants.Integrations.OAUTH_APP && oauthApp) {
const oauthAppToken = oauthApp.id;
const oauthAppSecret = oauthApp.client_secret;
headerText = (
<FormattedMessage
id={'installed_oauth_apps.header'}
defaultMessage='OAuth 2.0 Applications'
/>
);
helpText = [];
helpText.push(
<p key='add_oauth_app.doneHelp'>
<FormattedMessage
id='add_oauth_app.doneHelp'
defaultMessage='Your OAuth 2.0 application is set up. Please use the following Client ID and Client Secret when requesting authorization for your application (details at <link>oAuth 2 Applications</link>).'
values={{
link: (msg: string) => (
<ExternalLink
href='https://developers.mattermost.com/integrate/admin-guide/admin-oauth2/'
location='confirm_integration'
>
{msg}
</ExternalLink>
),
}}
/>
</p>,
);
helpText.push(
<p key='add_oauth_app.clientId'>
<FormattedMarkdownMessage
id='add_oauth_app.clientId'
defaultMessage='**Client ID**: {id}'
values={{id: oauthAppToken}}
/>
<CopyText
idMessage='integrations.copy_client_id'
defaultMessage='Copy Client Id'
value={oauthAppToken}
/>
<br/>
<FormattedMarkdownMessage
id='add_oauth_app.clientSecret'
defaultMessage='**Client Secret**: {secret}'
values={{secret: oauthAppSecret}}
/>
<CopyText
idMessage='integrations.copy_client_secret'
defaultMessage='Copy Client Secret'
value={oauthAppSecret}
/>
</p>,
);
helpText.push(
<p key='add_oauth_app.doneUrlHelp'>
<FormattedMessage
id='add_oauth_app.doneUrlHelp'
defaultMessage='Here are your authorized redirect URLs.'
/>
</p>,
);
tokenText = (
<p className='word-break--all'>
<FormattedMarkdownMessage
id='add_oauth_app.url'
defaultMessage='**URL(s)**: {url}'
values={{url: oauthApp.callback_urls.join(', ')}}
/>
</p>
);
} else if (type === Constants.Integrations.BOT && bot) {
const botToken = (new URLSearchParams(location.search)).get('token') || '';
headerText = (
<FormattedMessage
id='bots.manage.header'
defaultMessage='Bot Accounts'
/>
);
helpText = (
<p>
<FormattedMessage
id='bots.manage.created.text'
defaultMessage='Your bot account **{botname}** has been created successfully. Please use the following access token to connect to the bot (see [documentation](https://mattermost.com/pl/default-bot-accounts) for further details).'
values={{
botname: bot.display_name || bot.username,
strong: (msg: string) => <strong>{msg}</strong>,
link: (msg: string) => (
<ExternalLink
href='https://mattermost.com/pl/default-bot-accounts'
location='confirm_integration'
>
{msg}
</ExternalLink>
),
}}
/>
</p>
);
tokenText = (
<p className='word-break--all'>
<FormattedMarkdownMessage
id='add_outgoing_webhook.token'
defaultMessage='**Token**: {token}'
values={{token: botToken}}
/>
<CopyText value={botToken}/>
<br/>
<br/>
<FormattedMessage
id='add_outgoing_webhook.token.message'
defaultMessage='Make sure to add this bot account to teams and channels you want it to interact in. See <link>documentation</link> to learn more.'
values={{
link: (msg: string) => (
<ExternalLink
href='https://mattermost.com/pl/default-bot-accounts'
location='confirm_integration'
>
{msg}
</ExternalLink>
),
}}
/>
</p>
);
} else {
history.replace(`/error?type=${ErrorPageTypes.PAGE_NOT_FOUND}`);
return null;
}
return (
<div className='backstage-content row'>
<BackstageHeader>
<Link to={'/' + team.name + '/integrations/' + type}>
{headerText}
</Link>
<FormattedMessage
id='integrations.add'
defaultMessage='Add'
/>
</BackstageHeader>
<div className='backstage-form backstage-form__confirmation'>
<h4
className='backstage-form__title'
id='formTitle'
>
<FormattedMessage
id='integrations.successful'
defaultMessage='Setup Successful'
/>
</h4>
{helpText}
{tokenText}
<div className='backstage-form__footer'>
<Link
className='btn btn-primary'
type='submit'
to={'/' + team.name + '/integrations/' + type}
id='doneButton'
>
<FormattedMessage
id='integrations.done'
defaultMessage='Done'
/>
</Link>
</div>
</div>
</div>
);
};
export default ConfirmIntegration;

Просмотреть файл

@@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getCommands, getOAuthApps, getIncomingHooks, getOutgoingHooks} from 'mattermost-redux/selectors/entities/integrations';
import {getBotAccounts} from 'mattermost-redux/selectors/entities/bots';
import {GlobalState} from 'types/store';
import ConfirmIntegration from './confirm_integration';
function mapStateToProps(state: GlobalState) {
return {
commands: getCommands(state),
oauthApps: getOAuthApps(state),
incomingHooks: getIncomingHooks(state),
outgoingHooks: getOutgoingHooks(state),
bots: getBotAccounts(state),
};
}
export default connect(mapStateToProps)(ConfirmIntegration);

Просмотреть файл

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import {openModal as openModalAction} from 'actions/views/modals';
import ConfirmModalRedux from 'components/confirm_modal_redux';
import WarningIcon from 'components/widgets/icons/fa_warning_icon';
const ModalId = 'delete_integration_confirm';
type Props = {
confirmButtonText?: React.ReactNode;
linkText?: React.ReactNode;
modalMessage?: React.ReactNode;
modalTitle?: React.ReactNode;
onDelete: () => void;
openModal: typeof openModalAction;
};
export default function DeleteIntegrationLink(props: Props) {
const {
confirmButtonText = (
<FormattedMessage
id='integrations.delete.confirm.button'
defaultMessage='Delete'
/>
),
linkText = (
<FormattedMessage
id='installed_integrations.delete'
defaultMessage='Delete'
/>
),
modalMessage,
modalTitle = (
<FormattedMessage
id='integrations.delete.confirm.title'
defaultMessage='Delete Integration'
/>
),
onDelete,
openModal,
} = props;
const onClick = useCallback(() => {
openModal({
modalId: ModalId,
dialogProps: {
confirmButtonText,
message: (
<div className='alert alert-warning'>
<WarningIcon additionalClassName='mr-1'/>
{props.modalMessage}
</div>
),
onConfirm: onDelete,
title: modalTitle,
},
dialogType: ConfirmModalRedux,
});
}, [confirmButtonText, modalMessage, modalTitle, onDelete, openModal]);
return (
<button
className='color--link style--none'
onClick={onClick}
>
{linkText}
</button>
);
}

Просмотреть файл

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {openModal} from 'actions/views/modals';
import DeleteIntegrationLink from './delete_integration_link';
const mapDispatchToProps = {
openModal,
};
export default connect(null, mapDispatchToProps)(DeleteIntegrationLink);

Просмотреть файл

@@ -0,0 +1,125 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/EditCommand should have match renderExtra 1`] = `
<ConfirmModal
confirmButtonClass="btn btn-primary"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Update"
id="update_command.update"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="Your changes may break the existing slash command. Are you sure you would like to update it?"
id="update_command.question"
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Edit Slash Command"
id="update_command.confirm"
/>
}
/>
`;
exports[`components/integrations/EditCommand should match snapshot 1`] = `
<AbstractCommand
action={[Function]}
footer={
Object {
"defaultMessage": "Update",
"id": "edit_command.update",
}
}
header={
Object {
"defaultMessage": "Edit",
"id": "integrations.edit",
}
}
initialCommand={
Object {
"auto_complete": true,
"auto_complete_desc": "auto_complete_desc",
"auto_complete_hint": "auto_complete_hint",
"create_at": 1499722850203,
"creator_id": "88oybd1dwfdoxpkpw1h5kpbyco",
"delete_at": 0,
"description": "description",
"display_name": "display_name",
"icon_url": "https://google.com/icon",
"id": "r5tpgt4iepf45jt768jz84djic",
"method": "G",
"team_id": "m5gix3oye3du8ghk4ko6h9cq7y",
"token": "jb6oyqh95irpbx8fo9zmndkp1r",
"trigger": "trigger",
"update_at": 1504468859001,
"url": "https://google.com/command",
"username": "username",
}
}
loading={
Object {
"defaultMessage": "Updating...",
"id": "edit_command.updating",
}
}
renderExtra={
<ConfirmModal
confirmButtonClass="btn btn-primary"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Update"
id="update_command.update"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="Your changes may break the existing slash command. Are you sure you would like to update it?"
id="update_command.question"
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Edit Slash Command"
id="update_command.confirm"
/>
}
/>
}
serverError=""
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "m5gix3oye3du8ghk4ko6h9cq7y",
"invite_id": "",
"name": "test",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
/>
`;
exports[`components/integrations/EditCommand should match snapshot when EnableCommands is false 1`] = `<LoadingScreen />`;
exports[`components/integrations/EditCommand should match snapshot, loading 1`] = `<LoadingScreen />`;

Просмотреть файл

@@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {Team} from '@mattermost/types/teams';
import {Command} from '@mattermost/types/integrations';
import {TestHelper} from 'utils/test_helper';
import EditCommand from 'components/integrations/edit_command/edit_command';
describe('components/integrations/EditCommand', () => {
const getCustomTeamCommands = jest.fn(
() => {
return new Promise<Command[]>((resolve) => {
process.nextTick(() => resolve([]));
});
},
);
const commands = {
r5tpgt4iepf45jt768jz84djic: TestHelper.getCommandMock({
id: 'r5tpgt4iepf45jt768jz84djic',
display_name: 'display_name',
description: 'description',
trigger: 'trigger',
auto_complete: true,
auto_complete_hint: 'auto_complete_hint',
auto_complete_desc: 'auto_complete_desc',
token: 'jb6oyqh95irpbx8fo9zmndkp1r',
create_at: 1499722850203,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
delete_at: 0,
icon_url: 'https://google.com/icon',
method: 'G',
team_id: 'm5gix3oye3du8ghk4ko6h9cq7y',
update_at: 1504468859001,
url: 'https://google.com/command',
username: 'username',
}),
};
const team: Team = TestHelper.getTeamMock({
name: 'test',
id: 'm5gix3oye3du8ghk4ko6h9cq7y',
});
const editCommandRequest = {
status: 'not_started',
error: null,
};
const baseProps = {
team,
commandId: 'r5tpgt4iepf45jt768jz84djic',
commands,
editCommandRequest,
actions: {
getCustomTeamCommands,
editCommand: jest.fn(),
},
enableCommands: true,
};
test('should match snapshot', () => {
const wrapper = shallow(
<EditCommand {...baseProps}/>,
);
wrapper.setState({originalCommand: commands.r5tpgt4iepf45jt768jz84djic});
expect(wrapper).toMatchSnapshot();
expect(baseProps.actions.getCustomTeamCommands).toHaveBeenCalled();
expect(baseProps.actions.getCustomTeamCommands).toHaveBeenCalledWith(team.id);
});
test('should match snapshot, loading', () => {
const wrapper = shallow(
<EditCommand {...baseProps}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot when EnableCommands is false', () => {
const actions = {
getCustomTeamCommands: jest.fn(),
editCommand: jest.fn(),
};
const props = {...baseProps, actions, enableCommands: false};
const wrapper = shallow(
<EditCommand {...props}/>,
);
expect(wrapper).toMatchSnapshot();
expect(actions.getCustomTeamCommands).not.toHaveBeenCalled();
});
test('should have match state when handleConfirmModal is called', () => {
const props = {...baseProps, getCustomTeamCommands};
const wrapper = shallow<EditCommand>(
<EditCommand {...props}/>,
);
wrapper.setState({showConfirmModal: false});
wrapper.instance().handleConfirmModal();
expect(wrapper.state('showConfirmModal')).toEqual(true);
});
test('should have match state when confirmModalDismissed is called', () => {
const props = {...baseProps, getCustomTeamCommands};
const wrapper = shallow<EditCommand>(
<EditCommand {...props}/>,
);
wrapper.setState({showConfirmModal: true});
wrapper.instance().confirmModalDismissed();
expect(wrapper.state('showConfirmModal')).toEqual(false);
});
test('should have match renderExtra', () => {
const props = {...baseProps, getCustomTeamCommands};
const wrapper = shallow<EditCommand>(
<EditCommand {...props}/>,
);
expect(wrapper.instance().renderExtra()).toMatchSnapshot();
});
test('should have match when editCommand is called', () => {
const props = {...baseProps, getCustomTeamCommands};
const wrapper = shallow<EditCommand>(
<EditCommand {...props}/>,
);
wrapper.setState({originalCommand: commands.r5tpgt4iepf45jt768jz84djic});
const instance = wrapper.instance();
instance.handleConfirmModal = jest.fn();
instance.submitCommand = jest.fn();
wrapper.instance().editCommand(commands.r5tpgt4iepf45jt768jz84djic);
expect(instance.handleConfirmModal).not.toBeCalled();
expect(instance.submitCommand).toBeCalled();
});
});

Просмотреть файл

@@ -0,0 +1,182 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {Team} from '@mattermost/types/teams';
import {Command} from '@mattermost/types/integrations';
import {RelationOneToOne} from '@mattermost/types/utilities';
import {getHistory} from 'utils/browser_history';
import {t} from 'utils/i18n';
import LoadingScreen from 'components/loading_screen';
import ConfirmModal from 'components/confirm_modal';
import AbstractCommand from '../abstract_command.jsx';
const HEADER = {id: t('integrations.edit'), defaultMessage: 'Edit'};
const FOOTER = {id: t('edit_command.update'), defaultMessage: 'Update'};
const LOADING = {id: t('edit_command.updating'), defaultMessage: 'Updating...'};
type Props = {
/**
* The current team
*/
team: Team;
/**
* The id of the command to edit
*/
commandId: string | null;
/**
* Installed slash commands to display
*/
commands: RelationOneToOne<Command, Command>;
actions: {
/**
* The function to call to fetch team commands
*/
getCustomTeamCommands: (teamId: string) => Promise<Command[]>;
/**
* The function to call to edit command
*/
editCommand: (command?: Command) => Promise<{data?: Command; error?: Error}>;
};
/**
* Whether or not commands are enabled.
*/
enableCommands: boolean;
}
type State = {
originalCommand: Command | null;
showConfirmModal: boolean;
serverError: string;
}
export default class EditCommand extends React.PureComponent<Props, State> {
private newCommand?: Command;
public constructor(props: Props) {
super(props);
this.newCommand = undefined;
this.state = {
originalCommand: null,
showConfirmModal: false,
serverError: '',
};
}
public componentDidMount(): void {
if (this.props.enableCommands) {
this.props.actions.getCustomTeamCommands(this.props.team.id).then(
() => {
this.setState({
originalCommand: Object.values(this.props.commands).filter((command) => command.id === this.props.commandId)[0],
});
},
);
}
}
public editCommand = async (command: Command): Promise<void> => {
this.newCommand = command;
if (this.state.originalCommand?.id) {
command.id = this.state.originalCommand.id;
}
if (this.state.originalCommand?.url !== this.newCommand.url ||
this.state.originalCommand?.trigger !== this.newCommand.trigger ||
this.state.originalCommand?.method !== this.newCommand.method) {
this.handleConfirmModal();
} else {
await this.submitCommand();
}
}
public handleConfirmModal = (): void => {
this.setState({showConfirmModal: true});
}
public confirmModalDismissed = (): void => {
this.setState({showConfirmModal: false});
}
public submitCommand = async (): Promise<void> => {
this.setState({serverError: ''});
const {data, error} = await this.props.actions.editCommand(this.newCommand);
if (data) {
getHistory().push(`/${this.props.team.name}/integrations/commands`);
return;
}
this.setState({showConfirmModal: false});
if (error) {
this.setState({serverError: error.message});
}
}
public renderExtra = (): JSX.Element => {
const confirmButton = (
<FormattedMessage
id='update_command.update'
defaultMessage='Update'
/>
);
const confirmTitle = (
<FormattedMessage
id='update_command.confirm'
defaultMessage='Edit Slash Command'
/>
);
const confirmMessage = (
<FormattedMessage
id='update_command.question'
defaultMessage='Your changes may break the existing slash command. Are you sure you would like to update it?'
/>
);
return (
<ConfirmModal
title={confirmTitle}
message={confirmMessage}
confirmButtonText={confirmButton}
show={this.state.showConfirmModal}
onConfirm={this.submitCommand}
onCancel={this.confirmModalDismissed}
/>
);
}
public render(): JSX.Element {
if (!this.state.originalCommand) {
return <LoadingScreen/>;
}
return (
<AbstractCommand
team={this.props.team}
header={HEADER}
footer={FOOTER}
loading={LOADING}
renderExtra={this.renderExtra()}
action={this.editCommand}
serverError={this.state.serverError}
initialCommand={this.state.originalCommand}
/>
);
}
}

Просмотреть файл

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux';
import {editCommand, getCustomTeamCommands} from 'mattermost-redux/actions/integrations';
import {getCommands} from 'mattermost-redux/selectors/entities/integrations';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {GlobalState} from '@mattermost/types/store';
import {GenericAction, ActionFunc} from 'mattermost-redux/types/actions';
import {Command} from '@mattermost/types/integrations';
import EditCommand from './edit_command';
type Props = {
location: Location;
}
type Actions = {
getCustomTeamCommands: (teamId: string) => Promise<Command[]>;
editCommand: (command?: Command) => Promise<{data?: Command; error?: Error}>;
}
function mapStateToProps(state: GlobalState, ownProps: Props) {
const config = getConfig(state);
const commandId = (new URLSearchParams(ownProps.location.search)).get('id');
const enableCommands = config.EnableCommands === 'true';
return {
commandId,
commands: getCommands(state),
enableCommands,
};
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
getCustomTeamCommands,
editCommand,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(EditCommand);

Просмотреть файл

@@ -0,0 +1,253 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/EditIncomingWebhook should have called submitHook when editIncomingHook is initiated (no server error) 1`] = `
<AbstractIncomingWebhook
action={[Function]}
enablePostIconOverride={true}
enablePostUsernameOverride={true}
footer={
Object {
"defaultMessage": "Update",
"id": "update_incoming_webhook.update",
}
}
header={
Object {
"defaultMessage": "Edit",
"id": "integrations.edit",
}
}
initialHook={
Object {
"channel_id": "channel_id",
"channel_locked": false,
"create_at": 0,
"delete_at": 20,
"description": "description",
"display_name": "display_name",
"icon_url": "http://test/icon.png",
"id": "id",
"team_id": "team_id",
"update_at": 10,
"user_id": "user_id",
"username": "username",
}
}
loading={
Object {
"defaultMessage": "Updating...",
"id": "update_incoming_webhook.updating",
}
}
serverError=""
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "team_id",
"invite_id": "",
"name": "DN",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
/>
`;
exports[`components/integrations/EditIncomingWebhook should have called submitHook when editIncomingHook is initiated (with data) 1`] = `
<AbstractIncomingWebhook
action={[Function]}
enablePostIconOverride={true}
enablePostUsernameOverride={true}
footer={
Object {
"defaultMessage": "Update",
"id": "update_incoming_webhook.update",
}
}
header={
Object {
"defaultMessage": "Edit",
"id": "integrations.edit",
}
}
initialHook={
Object {
"channel_id": "channel_id",
"channel_locked": false,
"create_at": 0,
"delete_at": 20,
"description": "description",
"display_name": "display_name",
"icon_url": "http://test/icon.png",
"id": "id",
"team_id": "team_id",
"update_at": 10,
"user_id": "user_id",
"username": "username",
}
}
loading={
Object {
"defaultMessage": "Updating...",
"id": "update_incoming_webhook.updating",
}
}
serverError=""
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "team_id",
"invite_id": "",
"name": "DN",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
/>
`;
exports[`components/integrations/EditIncomingWebhook should have called submitHook when editIncomingHook is initiated (with server error) 1`] = `
<AbstractIncomingWebhook
action={[Function]}
enablePostIconOverride={true}
enablePostUsernameOverride={true}
footer={
Object {
"defaultMessage": "Update",
"id": "update_incoming_webhook.update",
}
}
header={
Object {
"defaultMessage": "Edit",
"id": "integrations.edit",
}
}
initialHook={
Object {
"channel_id": "channel_id",
"channel_locked": false,
"create_at": 0,
"delete_at": 20,
"description": "description",
"display_name": "display_name",
"icon_url": "http://test/icon.png",
"id": "id",
"team_id": "team_id",
"update_at": 10,
"user_id": "user_id",
"username": "username",
}
}
loading={
Object {
"defaultMessage": "Updating...",
"id": "update_incoming_webhook.updating",
}
}
serverError=""
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "team_id",
"invite_id": "",
"name": "DN",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
/>
`;
exports[`components/integrations/EditIncomingWebhook should not call getIncomingHook 1`] = `<LoadingScreen />`;
exports[`components/integrations/EditIncomingWebhook should show AbstractIncomingWebhook 1`] = `
<AbstractIncomingWebhook
action={[Function]}
enablePostIconOverride={true}
enablePostUsernameOverride={true}
footer={
Object {
"defaultMessage": "Update",
"id": "update_incoming_webhook.update",
}
}
header={
Object {
"defaultMessage": "Edit",
"id": "integrations.edit",
}
}
initialHook={
Object {
"channel_id": "channel_id",
"channel_locked": false,
"create_at": 0,
"delete_at": 20,
"description": "description",
"display_name": "display_name",
"icon_url": "http://test/icon.png",
"id": "id",
"team_id": "team_id",
"update_at": 10,
"user_id": "user_id",
"username": "username",
}
}
loading={
Object {
"defaultMessage": "Updating...",
"id": "update_incoming_webhook.updating",
}
}
serverError=""
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "team_id",
"invite_id": "",
"name": "DN",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
/>
`;
exports[`components/integrations/EditIncomingWebhook should show Loading screen when no hook is provided 1`] = `<LoadingScreen />`;

Просмотреть файл

@@ -0,0 +1,125 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {IncomingWebhook} from '@mattermost/types/integrations';
import {ActionResult} from 'mattermost-redux/types/actions';
import {getHistory} from 'utils/browser_history';
import EditIncomingWebhook from 'components/integrations/edit_incoming_webhook/edit_incoming_webhook';
import {TestHelper} from '../../../utils/test_helper';
describe('components/integrations/EditIncomingWebhook', () => {
const hook = {
id: 'id',
create_at: 0,
update_at: 10,
delete_at: 20,
user_id: 'user_id',
channel_id: 'channel_id',
team_id: 'team_id',
display_name: 'display_name',
description: 'description',
username: 'username',
icon_url: 'http://test/icon.png',
channel_locked: false,
};
const updateIncomingHook = jest.fn();
const getIncomingHook = jest.fn();
const actions = {
updateIncomingHook: updateIncomingHook as (hook: IncomingWebhook) => Promise<ActionResult>,
getIncomingHook: getIncomingHook as (hookId: string) => Promise<ActionResult>,
};
const requiredProps = {
hookId: 'somehookid',
teamId: 'testteamid',
team: TestHelper.getTeamMock(),
updateIncomingHookRequest: {
status: 'not_started',
error: null,
},
enableIncomingWebhooks: true,
enablePostUsernameOverride: true,
enablePostIconOverride: true,
};
afterEach(() => {
updateIncomingHook.mockReset();
getIncomingHook.mockReset();
});
test('should show Loading screen when no hook is provided', () => {
const props = {...requiredProps, actions};
const wrapper = shallow(<EditIncomingWebhook {...props}/>);
expect(wrapper).toMatchSnapshot();
expect(getIncomingHook).toHaveBeenCalledTimes(1);
expect(getIncomingHook).toBeCalledWith(props.hookId);
});
test('should show AbstractIncomingWebhook', () => {
const props = {...requiredProps, actions, hook};
const wrapper = shallow(<EditIncomingWebhook {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should not call getIncomingHook', () => {
const props = {...requiredProps, enableIncomingWebhooks: false, actions};
const wrapper = shallow(<EditIncomingWebhook {...props}/>);
expect(wrapper).toMatchSnapshot();
expect(getIncomingHook).toHaveBeenCalledTimes(0);
});
test('should have called submitHook when editIncomingHook is initiated (no server error)', async () => {
const newUpdateIncomingHook = jest.fn().mockReturnValue({data: ''});
const newActions = {...actions, updateIncomingHook: newUpdateIncomingHook};
const asyncHook = {...hook};
const props = {...requiredProps, actions: newActions, hook};
const wrapper = shallow<EditIncomingWebhook>(<EditIncomingWebhook {...props}/>);
const instance = wrapper.instance();
await instance.editIncomingHook(asyncHook);
expect(wrapper).toMatchSnapshot();
expect(newActions.updateIncomingHook).toHaveBeenCalledTimes(1);
expect(newActions.updateIncomingHook).toBeCalledWith(asyncHook);
expect(wrapper.state('serverError')).toEqual('');
});
test('should have called submitHook when editIncomingHook is initiated (with server error)', async () => {
const newUpdateIncomingHook = jest.fn().mockReturnValue({data: ''});
const newActions = {...actions, updateIncomingHook: newUpdateIncomingHook};
const asyncHook = {...hook};
const props = {...requiredProps, actions: newActions, hook};
const wrapper = shallow<EditIncomingWebhook>(<EditIncomingWebhook {...props}/>);
const instance = wrapper.instance();
await instance.editIncomingHook(asyncHook);
expect(wrapper).toMatchSnapshot();
expect(newActions.updateIncomingHook).toHaveBeenCalledTimes(1);
expect(newActions.updateIncomingHook).toBeCalledWith(asyncHook);
});
test('should have called submitHook when editIncomingHook is initiated (with data)', async () => {
const newUpdateIncomingHook = jest.fn().mockReturnValue({data: 'data'});
const newActions = {...actions, updateIncomingHook: newUpdateIncomingHook};
const asyncHook = {...hook};
const props = {...requiredProps, actions: newActions, hook};
const wrapper = shallow<EditIncomingWebhook>(<EditIncomingWebhook {...props}/>);
const instance = wrapper.instance();
await instance.editIncomingHook(asyncHook);
expect(wrapper).toMatchSnapshot();
expect(newUpdateIncomingHook).toHaveBeenCalledTimes(1);
expect(newUpdateIncomingHook).toBeCalledWith(asyncHook);
expect(wrapper.state('serverError')).toEqual('');
expect(getHistory().push).toHaveBeenCalledWith(`/${requiredProps.team.name}/integrations/incoming_webhooks`);
});
});

Просмотреть файл

@@ -0,0 +1,137 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Team} from '@mattermost/types/teams';
import {IncomingWebhook} from '@mattermost/types/integrations';
import {ActionResult} from 'mattermost-redux/types/actions';
import {getHistory} from 'utils/browser_history';
import {t} from 'utils/i18n';
import AbstractIncomingWebhook from 'components/integrations/abstract_incoming_webhook';
import LoadingScreen from 'components/loading_screen';
const HEADER = {id: t('integrations.edit'), defaultMessage: 'Edit'};
const FOOTER = {id: t('update_incoming_webhook.update'), defaultMessage: 'Update'};
const LOADING = {id: t('update_incoming_webhook.updating'), defaultMessage: 'Updating...'};
type Props = {
/**
* The current team
*/
team: Team;
/**
* The incoming webhook to edit
*/
hook?: IncomingWebhook;
/**
* The id of the incoming webhook to edit
*/
hookId: string;
/**
* Whether or not incoming webhooks are enabled.
*/
enableIncomingWebhooks: boolean;
/**
* Whether to allow configuration of the default post username.
*/
enablePostUsernameOverride: boolean;
/**
* Whether to allow configuration of the default post icon.
*/
enablePostIconOverride: boolean;
actions: {
/**
* The function to call to update an incoming webhook
*/
updateIncomingHook: (hook: IncomingWebhook) => Promise<ActionResult>;
/**
* The function to call to get an incoming webhook
*/
getIncomingHook: (hookId: string) => Promise<ActionResult>;
};
};
type State = {
showConfirmModal: boolean;
serverError: string;
};
export default class EditIncomingWebhook extends React.PureComponent<Props, State> {
private newHook?: IncomingWebhook;
constructor(props: Props) {
super(props);
this.state = {
showConfirmModal: false,
serverError: '',
};
}
componentDidMount() {
if (this.props.enableIncomingWebhooks) {
this.props.actions.getIncomingHook(this.props.hookId);
}
}
editIncomingHook = async (hook: IncomingWebhook) => {
this.newHook = hook;
if (this.props.hook?.id) {
hook.id = this.props.hook.id;
}
await this.submitHook();
};
submitHook = async () => {
this.setState({serverError: ''});
if (!this.newHook) {
return;
}
const result = await this.props.actions.updateIncomingHook(this.newHook);
if ('data' in result) {
getHistory().push(`/${this.props.team.name}/integrations/incoming_webhooks`);
return;
}
if ('error' in result) {
const {error} = result;
this.setState({serverError: error.message});
}
};
render() {
if (!this.props.hook) {
return <LoadingScreen/>;
}
return (
<AbstractIncomingWebhook
team={this.props.team}
header={HEADER}
footer={FOOTER}
loading={LOADING}
enablePostUsernameOverride={this.props.enablePostUsernameOverride}
enablePostIconOverride={this.props.enablePostIconOverride}
action={this.editIncomingHook}
serverError={this.state.serverError}
initialHook={this.props.hook}
/>
);
}
}

Просмотреть файл

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {getIncomingHook, updateIncomingHook} from 'mattermost-redux/actions/integrations';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {GlobalState} from '@mattermost/types/store';
import {ActionFunc, ActionResult, GenericAction} from 'mattermost-redux/types/actions';
import {IncomingWebhook} from '@mattermost/types/integrations';
import EditIncomingWebhook from './edit_incoming_webhook';
type Props = {
location: Location;
}
type Actions = {
updateIncomingHook: (hook: IncomingWebhook) => Promise<ActionResult>;
getIncomingHook: (hookId: string) => Promise<ActionResult>;
}
function mapStateToProps(state: GlobalState, ownProps: Props) {
const config = getConfig(state);
const enableIncomingWebhooks = config.EnableIncomingWebhooks === 'true';
const enablePostUsernameOverride = config.EnablePostUsernameOverride === 'true';
const enablePostIconOverride = config.EnablePostIconOverride === 'true';
const hookId = (new URLSearchParams(ownProps.location.search)).get('id') || '';
return {
hookId,
hook: state.entities.integrations.incomingHooks[hookId],
enableIncomingWebhooks,
enablePostUsernameOverride,
enablePostIconOverride,
};
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
updateIncomingHook,
getIncomingHook,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(EditIncomingWebhook);

Просмотреть файл

@@ -0,0 +1,257 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/EditOAuthApp should have match renderExtra 1`] = `
<ConfirmModal
confirmButtonClass="btn btn-primary"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Update"
id="update_command.update"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="Your changes may break the existing OAuth 2.0 application. Are you sure you would like to update it?"
id="update_oauth_app.question"
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Edit OAuth 2.0 application"
id="update_oauth_app.confirm"
/>
}
/>
`;
exports[`components/integrations/EditOAuthApp should match snapshot 1`] = `
<AbstractOAuthApp
action={[Function]}
footer={
Object {
"defaultMessage": "Update",
"id": "update_incoming_webhook.update",
}
}
header={
Object {
"defaultMessage": "Edit",
"id": "integrations.edit",
}
}
initialApp={
Object {
"callback_urls": Array [
"https://test.com/callback",
"https://test.com/callback2",
],
"client_secret": "88cxd9wpzpbpfp8pad78xj75pr",
"create_at": 1501365458934,
"creator_id": "88oybd1dwfdoxpkpw1h5kpbyco",
"description": "testing",
"homepage": "https://test.com",
"icon_url": "https://test.com/icon",
"id": "facxd9wpzpbpfp8pad78xj75pr",
"is_trusted": true,
"name": "testApp",
"update_at": 1501365458934,
}
}
loading={
Object {
"defaultMessage": "Updating...",
"id": "update_incoming_webhook.updating",
}
}
renderExtra={
<ConfirmModal
confirmButtonClass="btn btn-primary"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Update"
id="update_command.update"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="Your changes may break the existing OAuth 2.0 application. Are you sure you would like to update it?"
id="update_oauth_app.question"
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Edit OAuth 2.0 application"
id="update_oauth_app.confirm"
/>
}
/>
}
serverError=""
team={
Object {
"id": "dbcxd9wpzpbpfp8pad78xj12pr",
"name": "test",
}
}
/>
`;
exports[`components/integrations/EditOAuthApp should match snapshot when EnableOAuthServiceProvider is false 1`] = `
<AbstractOAuthApp
action={[Function]}
footer={
Object {
"defaultMessage": "Update",
"id": "update_incoming_webhook.update",
}
}
header={
Object {
"defaultMessage": "Edit",
"id": "integrations.edit",
}
}
initialApp={
Object {
"callback_urls": Array [
"https://test.com/callback",
"https://test.com/callback2",
],
"client_secret": "88cxd9wpzpbpfp8pad78xj75pr",
"create_at": 1501365458934,
"creator_id": "88oybd1dwfdoxpkpw1h5kpbyco",
"description": "testing",
"homepage": "https://test.com",
"icon_url": "https://test.com/icon",
"id": "facxd9wpzpbpfp8pad78xj75pr",
"is_trusted": true,
"name": "testApp",
"update_at": 1501365458934,
}
}
loading={
Object {
"defaultMessage": "Updating...",
"id": "update_incoming_webhook.updating",
}
}
renderExtra={
<ConfirmModal
confirmButtonClass="btn btn-primary"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Update"
id="update_command.update"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="Your changes may break the existing OAuth 2.0 application. Are you sure you would like to update it?"
id="update_oauth_app.question"
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Edit OAuth 2.0 application"
id="update_oauth_app.confirm"
/>
}
/>
}
serverError=""
team={
Object {
"id": "dbcxd9wpzpbpfp8pad78xj12pr",
"name": "test",
}
}
/>
`;
exports[`components/integrations/EditOAuthApp should match snapshot, loading 1`] = `
<AbstractOAuthApp
action={[Function]}
footer={
Object {
"defaultMessage": "Update",
"id": "update_incoming_webhook.update",
}
}
header={
Object {
"defaultMessage": "Edit",
"id": "integrations.edit",
}
}
initialApp={
Object {
"callback_urls": Array [
"https://test.com/callback",
"https://test.com/callback2",
],
"client_secret": "88cxd9wpzpbpfp8pad78xj75pr",
"create_at": 1501365458934,
"creator_id": "88oybd1dwfdoxpkpw1h5kpbyco",
"description": "testing",
"homepage": "https://test.com",
"icon_url": "https://test.com/icon",
"id": "facxd9wpzpbpfp8pad78xj75pr",
"is_trusted": true,
"name": "testApp",
"update_at": 1501365458934,
}
}
loading={
Object {
"defaultMessage": "Updating...",
"id": "update_incoming_webhook.updating",
}
}
renderExtra={
<ConfirmModal
confirmButtonClass="btn btn-primary"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Update"
id="update_command.update"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="Your changes may break the existing OAuth 2.0 application. Are you sure you would like to update it?"
id="update_oauth_app.question"
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Edit OAuth 2.0 application"
id="update_oauth_app.confirm"
/>
}
/>
}
serverError=""
team={
Object {
"id": "dbcxd9wpzpbpfp8pad78xj12pr",
"name": "test",
}
}
/>
`;

Просмотреть файл

@@ -0,0 +1,172 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {OAuthApp} from '@mattermost/types/integrations';
import {Team} from '@mattermost/types/teams';
import {getHistory} from 'utils/browser_history';
import EditOAuthApp from 'components/integrations/edit_oauth_app/edit_oauth_app';
describe('components/integrations/EditOAuthApp', () => {
const oauthApp: OAuthApp = {
id: 'facxd9wpzpbpfp8pad78xj75pr',
name: 'testApp',
client_secret: '88cxd9wpzpbpfp8pad78xj75pr',
create_at: 1501365458934,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
description: 'testing',
homepage: 'https://test.com',
icon_url: 'https://test.com/icon',
is_trusted: true,
update_at: 1501365458934,
callback_urls: ['https://test.com/callback', 'https://test.com/callback2'],
};
const team: Team = {
id: 'dbcxd9wpzpbpfp8pad78xj12pr',
name: 'test',
} as Team;
const editOAuthAppRequest = {
status: 'not_started',
error: null,
};
const baseProps = {
team,
oauthAppId: oauthApp.id,
editOAuthAppRequest,
actions: {
getOAuthApp: jest.fn(),
editOAuthApp: jest.fn(),
},
enableOAuthServiceProvider: true,
};
test('should match snapshot, loading', () => {
const props = {...baseProps, oauthApp};
const wrapper = shallow(
<EditOAuthApp {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot', () => {
const props = {...baseProps, oauthApp};
const wrapper = shallow(
<EditOAuthApp {...props}/>,
);
expect(wrapper).toMatchSnapshot();
expect(props.actions.getOAuthApp).toHaveBeenCalledWith(oauthApp.id);
});
test('should match snapshot when EnableOAuthServiceProvider is false', () => {
const props = {...baseProps, oauthApp, enableOAuthServiceProvider: false};
const wrapper = shallow(
<EditOAuthApp {...props}/>,
);
expect(wrapper).toMatchSnapshot();
expect(props.actions.getOAuthApp).not.toHaveBeenCalledWith();
});
test('should have match state when handleConfirmModal is called', () => {
const props = {...baseProps, oauthApp};
const wrapper = shallow<EditOAuthApp>(
<EditOAuthApp {...props}/>,
);
wrapper.setState({showConfirmModal: false});
wrapper.instance().handleConfirmModal();
expect(wrapper.state('showConfirmModal')).toEqual(true);
});
test('should have match state when confirmModalDismissed is called', () => {
const props = {...baseProps, oauthApp};
const wrapper = shallow<EditOAuthApp>(
<EditOAuthApp {...props}/>,
);
wrapper.setState({showConfirmModal: true});
wrapper.instance().confirmModalDismissed();
expect(wrapper.state('showConfirmModal')).toEqual(false);
});
test('should have match renderExtra', () => {
const props = {...baseProps, oauthApp};
const wrapper = shallow<EditOAuthApp>(
<EditOAuthApp {...props}/>,
);
expect(wrapper.instance().renderExtra()).toMatchSnapshot();
});
test('should have match when editOAuthApp is called', () => {
const props = {...baseProps, oauthApp};
const wrapper = shallow<EditOAuthApp>(
<EditOAuthApp {...props}/>,
);
const instance = wrapper.instance();
instance.handleConfirmModal = jest.fn();
instance.submitOAuthApp = jest.fn();
instance.editOAuthApp(oauthApp);
expect(instance.handleConfirmModal).not.toBeCalled();
expect(instance.submitOAuthApp).toBeCalled();
});
test('should have match when submitOAuthApp is called on success', async () => {
baseProps.actions.editOAuthApp = jest.fn().mockImplementation(
() => {
return new Promise((resolve) => {
process.nextTick(() => resolve({
data: 'data',
error: null,
}));
});
},
);
const props = {...baseProps, oauthApp};
const wrapper = shallow<EditOAuthApp>(
<EditOAuthApp {...props}/>,
);
const instance = wrapper.instance();
wrapper.setState({showConfirmModal: true});
await instance.submitOAuthApp();
expect(wrapper.state('serverError')).toEqual('');
expect(getHistory().push).toHaveBeenCalledWith(`/${team.name}/integrations/oauth2-apps`);
});
test('should have match when submitOAuthApp is called on error', async () => {
baseProps.actions.editOAuthApp = jest.fn().mockImplementation(
() => {
return new Promise((resolve) => {
process.nextTick(() => resolve({
data: null,
error: {message: 'error message'},
}));
});
},
);
const props = {...baseProps, oauthApp};
const wrapper = shallow<EditOAuthApp>(
<EditOAuthApp {...props}/>,
);
const instance = wrapper.instance();
wrapper.setState({showConfirmModal: true});
await instance.submitOAuthApp();
expect(wrapper.state('showConfirmModal')).toEqual(false);
expect(wrapper.state('serverError')).toEqual('error message');
});
});

Просмотреть файл

@@ -0,0 +1,152 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {OAuthApp} from '@mattermost/types/integrations';
import {Team} from '@mattermost/types/teams';
import {ActionResult} from 'mattermost-redux/types/actions';
import {getHistory} from 'utils/browser_history';
import LoadingScreen from 'components/loading_screen';
import ConfirmModal from 'components/confirm_modal';
import AbstractOAuthApp from '../abstract_oauth_app.jsx';
const HEADER = {id: 'integrations.edit', defaultMessage: 'Edit'};
const FOOTER = {id: 'update_incoming_webhook.update', defaultMessage: 'Update'};
const LOADING = {id: 'update_incoming_webhook.updating', defaultMessage: 'Updating...'};
type Actions = {
getOAuthApp: (id: string) => OAuthApp;
editOAuthApp: (app: OAuthApp) => Promise<ActionResult>;
};
type Props = {
team: Team;
oauthAppId: string;
oauthApp: OAuthApp;
actions: Actions;
enableOAuthServiceProvider: boolean;
};
type State = {
showConfirmModal: boolean;
serverError: string;
};
export default class EditOAuthApp extends React.PureComponent<Props, State> {
newApp: OAuthApp;
constructor(props: Props) {
super(props);
this.state = {
showConfirmModal: false,
serverError: '',
};
this.newApp = this.props.oauthApp;
}
componentDidMount() {
if (this.props.enableOAuthServiceProvider) {
this.props.actions.getOAuthApp(this.props.oauthAppId);
}
}
editOAuthApp = async (app: OAuthApp) => {
this.newApp = app;
if (this.props.oauthApp.id) {
app.id = this.props.oauthApp.id;
}
const callbackUrlsSame = (this.props.oauthApp.callback_urls.length === app.callback_urls.length) &&
this.props.oauthApp.callback_urls.every((v, i) => v === app.callback_urls[i]);
if (callbackUrlsSame === false) {
this.handleConfirmModal();
} else {
await this.submitOAuthApp();
}
}
handleConfirmModal = () => {
this.setState({showConfirmModal: true});
}
confirmModalDismissed = () => {
this.setState({showConfirmModal: false});
}
submitOAuthApp = async () => {
this.setState({serverError: ''});
const res = await this.props.actions.editOAuthApp(this.newApp);
if ('data' in res && res.data) {
getHistory().push(`/${this.props.team.name}/integrations/oauth2-apps`);
return;
}
this.setState({showConfirmModal: false});
if ('error' in res) {
const {error: err} = res;
this.setState({serverError: err.message});
}
}
renderExtra = () => {
const confirmButton = (
<FormattedMessage
id='update_command.update'
defaultMessage='Update'
/>
);
const confirmTitle = (
<FormattedMessage
id='update_oauth_app.confirm'
defaultMessage='Edit OAuth 2.0 application'
/>
);
const confirmMessage = (
<FormattedMessage
id='update_oauth_app.question'
defaultMessage='Your changes may break the existing OAuth 2.0 application. Are you sure you would like to update it?'
/>
);
return (
<ConfirmModal
title={confirmTitle}
message={confirmMessage}
confirmButtonText={confirmButton}
show={this.state.showConfirmModal}
onConfirm={this.submitOAuthApp}
onCancel={this.confirmModalDismissed}
/>
);
}
render() {
if (!this.props.oauthApp) {
return <LoadingScreen/>;
}
return (
<AbstractOAuthApp
team={this.props.team}
header={HEADER}
footer={FOOTER}
loading={LOADING}
renderExtra={this.renderExtra()}
action={this.editOAuthApp}
serverError={this.state.serverError}
initialApp={this.props.oauthApp}
/>
);
}
}

Просмотреть файл

@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux';
import {GlobalState} from '@mattermost/types/store';
import {getOAuthApp, editOAuthApp} from 'mattermost-redux/actions/integrations';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {ActionFunc, ActionResult} from 'mattermost-redux/types/actions';
import {OAuthApp} from '@mattermost/types/integrations';
import EditOAuthApp from './edit_oauth_app';
type Actions = {
getOAuthApp: (id: string) => OAuthApp;
editOAuthApp: (app: OAuthApp) => Promise<ActionResult>;
};
type Props = {
location: Location;
};
function mapStateToProps(state: GlobalState, ownProps: Props) {
const config = getConfig(state);
const oauthAppId: string = (new URLSearchParams(ownProps.location.search)).get('id') || '';
const enableOAuthServiceProvider = config.EnableOAuthServiceProvider === 'true';
return {
oauthAppId,
oauthApp: state.entities.integrations.oauthApps[oauthAppId],
enableOAuthServiceProvider,
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
getOAuthApp,
editOAuthApp,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(EditOAuthApp);

Просмотреть файл

@@ -0,0 +1,229 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/EditOutgoingWebhook should have match renderExtra 1`] = `
<ConfirmModal
confirmButtonClass="btn btn-primary"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Update"
id="update_outgoing_webhook.update"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="Your changes may break the existing outgoing webhook. Are you sure you would like to update it?"
id="update_outgoing_webhook.question"
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Edit Outgoing Webhook"
id="update_outgoing_webhook.confirm"
/>
}
/>
`;
exports[`components/integrations/EditOutgoingWebhook should match snapshot 1`] = `
<AbstractOutgoingWebhook
action={[Function]}
enablePostIconOverride={false}
enablePostUsernameOverride={false}
footer={
Object {
"defaultMessage": "Update",
"id": "update_outgoing_webhook.update",
}
}
header={
Object {
"defaultMessage": "Edit",
"id": "integrations.edit",
}
}
initialHook={
Object {
"callback_urls": Array [
"https://test.com/callback",
"https://test.com/callback2",
],
"channel_id": "r18hw9hgq3gtiymtpb6epf8qtr",
"content_type": "application/json",
"create_at": 1504447824673,
"creator_id": "88oybd1dwfdoxpkpw1h5kpbyco",
"delete_at": 0,
"description": "description",
"display_name": "name",
"icon_url": "",
"id": "ne8miib4dtde5jmgwqsoiwxpiy",
"team_id": "m5gix3oye3du8ghk4ko6h9cq7y",
"token": "nbxtx9hkhb8a5q83gw57jzi9cc",
"trigger_when": 0,
"trigger_words": Array [
"trigger",
"trigger2",
],
"update_at": 1504447824673,
"username": "",
}
}
loading={
Object {
"defaultMessage": "Updating...",
"id": "update_outgoing_webhook.updating",
}
}
renderExtra={
<ConfirmModal
confirmButtonClass="btn btn-primary"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Update"
id="update_outgoing_webhook.update"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="Your changes may break the existing outgoing webhook. Are you sure you would like to update it?"
id="update_outgoing_webhook.question"
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Edit Outgoing Webhook"
id="update_outgoing_webhook.confirm"
/>
}
/>
}
serverError=""
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "team_id",
"invite_id": "",
"name": "DN",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
/>
`;
exports[`components/integrations/EditOutgoingWebhook should match snapshot when EnableOutgoingWebhooks is false 1`] = `
<AbstractOutgoingWebhook
action={[Function]}
enablePostIconOverride={false}
enablePostUsernameOverride={false}
footer={
Object {
"defaultMessage": "Update",
"id": "update_outgoing_webhook.update",
}
}
header={
Object {
"defaultMessage": "Edit",
"id": "integrations.edit",
}
}
initialHook={
Object {
"callback_urls": Array [
"https://test.com/callback",
"https://test.com/callback2",
],
"channel_id": "r18hw9hgq3gtiymtpb6epf8qtr",
"content_type": "application/json",
"create_at": 1504447824673,
"creator_id": "88oybd1dwfdoxpkpw1h5kpbyco",
"delete_at": 0,
"description": "description",
"display_name": "name",
"icon_url": "",
"id": "ne8miib4dtde5jmgwqsoiwxpiy",
"team_id": "m5gix3oye3du8ghk4ko6h9cq7y",
"token": "nbxtx9hkhb8a5q83gw57jzi9cc",
"trigger_when": 0,
"trigger_words": Array [
"trigger",
"trigger2",
],
"update_at": 1504447824673,
"username": "",
}
}
loading={
Object {
"defaultMessage": "Updating...",
"id": "update_outgoing_webhook.updating",
}
}
renderExtra={
<ConfirmModal
confirmButtonClass="btn btn-primary"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Update"
id="update_outgoing_webhook.update"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="Your changes may break the existing outgoing webhook. Are you sure you would like to update it?"
id="update_outgoing_webhook.question"
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Edit Outgoing Webhook"
id="update_outgoing_webhook.confirm"
/>
}
/>
}
serverError=""
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "team_id",
"invite_id": "",
"name": "DN",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
/>
`;
exports[`components/integrations/EditOutgoingWebhook should match snapshot, loading 1`] = `<LoadingScreen />`;

Просмотреть файл

@@ -0,0 +1,157 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {OutgoingWebhook} from '@mattermost/types/integrations';
import EditOutgoingWebhook
from 'components/integrations/edit_outgoing_webhook/edit_outgoing_webhook';
import {getHistory} from 'utils/browser_history';
import {TestHelper} from 'utils/test_helper';
describe('components/integrations/EditOutgoingWebhook', () => {
const team = TestHelper.getTeamMock();
const hook: OutgoingWebhook = {
icon_url: '',
username: '',
id: 'ne8miib4dtde5jmgwqsoiwxpiy',
token: 'nbxtx9hkhb8a5q83gw57jzi9cc',
create_at: 1504447824673,
update_at: 1504447824673,
delete_at: 0,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
channel_id: 'r18hw9hgq3gtiymtpb6epf8qtr',
team_id: 'm5gix3oye3du8ghk4ko6h9cq7y',
trigger_words: ['trigger', 'trigger2'],
trigger_when: 0,
callback_urls: ['https://test.com/callback', 'https://test.com/callback2'],
display_name: 'name',
description: 'description',
content_type: 'application/json',
};
const updateOutgoingHookRequest = {
status: 'not_started',
error: null,
};
const baseProps = {
team,
hookId: 'hook_id',
updateOutgoingHookRequest,
actions: {
updateOutgoingHook: jest.fn(),
getOutgoingHook: jest.fn(),
},
enableOutgoingWebhooks: true,
enablePostUsernameOverride: false,
enablePostIconOverride: false,
};
test('should match snapshot', () => {
const props = {...baseProps, hook};
const wrapper = shallow(
<EditOutgoingWebhook {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, loading', () => {
const wrapper = shallow(
<EditOutgoingWebhook {...baseProps}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot when EnableOutgoingWebhooks is false', () => {
const props = {...baseProps, enableOutgoingWebhooks: false, hook};
const wrapper = shallow(
<EditOutgoingWebhook {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should have match state when handleConfirmModal is called', () => {
const props = {...baseProps, hook};
const wrapper = shallow<EditOutgoingWebhook>(
<EditOutgoingWebhook {...props}/>,
);
wrapper.setState({showConfirmModal: false});
wrapper.instance().handleConfirmModal();
expect(wrapper.state('showConfirmModal')).toEqual(true);
});
test('should have match state when confirmModalDismissed is called', () => {
const props = {...baseProps, hook};
const wrapper = shallow<EditOutgoingWebhook>(
<EditOutgoingWebhook {...props}/>,
);
wrapper.setState({showConfirmModal: true});
wrapper.instance().confirmModalDismissed();
expect(wrapper.state('showConfirmModal')).toEqual(false);
});
test('should have match renderExtra', () => {
const props = {...baseProps, hook};
const wrapper = shallow<EditOutgoingWebhook>(
<EditOutgoingWebhook {...props}/>,
);
expect(wrapper.instance().renderExtra()).toMatchSnapshot();
});
test('should have match when editOutgoingHook is called', () => {
const props = {...baseProps, hook};
const wrapper = shallow<EditOutgoingWebhook>(
<EditOutgoingWebhook {...props}/>,
);
const instance = wrapper.instance();
instance.handleConfirmModal = jest.fn();
instance.submitHook = jest.fn();
wrapper.instance().editOutgoingHook(hook);
expect(instance.handleConfirmModal).not.toBeCalled();
expect(instance.submitHook).toBeCalled();
});
test('should have match when submitHook is called on success', async () => {
const newActions = {
...baseProps.actions,
updateOutgoingHook: jest.fn().mockReturnValue({data: 'data'}),
};
const props = {...baseProps, hook, actions: newActions};
const wrapper = shallow<EditOutgoingWebhook>(
<EditOutgoingWebhook {...props}/>,
);
const instance = wrapper.instance();
wrapper.setState({showConfirmModal: true});
await instance.submitHook();
expect(newActions.updateOutgoingHook).toHaveBeenCalledTimes(1);
expect(wrapper.state('serverError')).toEqual('');
expect(getHistory().push).toHaveBeenCalledWith(`/${team.name}/integrations/outgoing_webhooks`);
});
test('should have match when submitHook is called on error', async () => {
const newActions = {
...baseProps.actions,
updateOutgoingHook: jest.fn().mockReturnValue({data: ''}),
};
const props = {...baseProps, hook, actions: newActions};
const wrapper = shallow<EditOutgoingWebhook>(
<EditOutgoingWebhook {...props}/>,
);
const instance = wrapper.instance();
wrapper.setState({showConfirmModal: true});
await instance.submitHook();
expect(wrapper.state('showConfirmModal')).toEqual(false);
});
});

Просмотреть файл

@@ -0,0 +1,191 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {Team} from '@mattermost/types/teams';
import {OutgoingWebhook} from '@mattermost/types/integrations';
import {ServerError} from '@mattermost/types/errors';
import {getHistory} from 'utils/browser_history';
import ConfirmModal from 'components/confirm_modal';
import AbstractOutgoingWebhook from 'components/integrations/abstract_outgoing_webhook.jsx';
import LoadingScreen from 'components/loading_screen';
const HEADER = {id: 'integrations.edit', defaultMessage: 'Edit'};
const FOOTER = {id: 'update_outgoing_webhook.update', defaultMessage: 'Update'};
const LOADING = {id: 'update_outgoing_webhook.updating', defaultMessage: 'Updating...'};
interface Props {
/**
* The current team
*/
team: Team;
/**
* The outgoing webhook to edit
*/
hook?: OutgoingWebhook;
/**
* The id of the outgoing webhook to edit
*/
hookId: string;
actions: {
/**
* The function to call to update an outgoing webhook
*/
updateOutgoingHook: (hook: OutgoingWebhook) => Promise<{ data: OutgoingWebhook; error: ServerError }>;
/**
* The function to call to get an outgoing webhook
*/
getOutgoingHook: (hookId: string) => Promise<{ data: OutgoingWebhook; error: ServerError }>;
};
/**
* Whether or not outgoing webhooks are enabled.
*/
enableOutgoingWebhooks?: boolean;
/**
* Whether to allow configuration of the default post username.
*/
enablePostUsernameOverride: boolean;
/**
* Whether to allow configuration of the default post icon.
*/
enablePostIconOverride: boolean;
}
interface State {
showConfirmModal: boolean;
serverError: string;
}
export default class EditOutgoingWebhook extends React.PureComponent<Props, State> {
private newHook: OutgoingWebhook | undefined;
constructor(props: Props) {
super(props);
this.state = {
showConfirmModal: false,
serverError: '',
};
}
componentDidMount(): void {
if (this.props.enableOutgoingWebhooks) {
this.props.actions.getOutgoingHook(this.props.hookId);
}
}
editOutgoingHook = async (hook: OutgoingWebhook): Promise<void> => {
this.newHook = hook;
if (this.props.hook!.id) {
hook.id = this.props.hook!.id;
}
if (this.props.hook!.token) {
hook.token = this.props.hook!.token;
}
const triggerWordsSame = (this.props.hook!.trigger_words.length === hook!.trigger_words.length) &&
this.props.hook!.trigger_words.every((v, i) => v === hook.trigger_words[i]);
const callbackUrlsSame = (this.props.hook!.callback_urls.length === hook!.callback_urls.length) &&
this.props.hook!.callback_urls.every((v, i) => v === hook.callback_urls[i]);
if (this.props.hook!.content_type !== hook.content_type ||
!triggerWordsSame || !callbackUrlsSame) {
this.handleConfirmModal();
} else {
await this.submitHook();
}
}
handleConfirmModal = (): void => {
this.setState({showConfirmModal: true});
}
confirmModalDismissed = (): void => {
this.setState({showConfirmModal: false});
}
submitHook = async (): Promise<void> => {
this.setState({serverError: ''});
const {data, error}: {data: OutgoingWebhook; error: ServerError} = await this.props.actions.updateOutgoingHook(this.newHook!);
if (data) {
getHistory().push(`/${this.props.team.name}/integrations/outgoing_webhooks`);
return;
}
this.setState({showConfirmModal: false});
if (error) {
this.setState({serverError: error.message});
}
}
renderExtra = (): JSX.Element => {
const confirmButton = (
<FormattedMessage
id='update_outgoing_webhook.update'
defaultMessage='Update'
/>
);
const confirmTitle = (
<FormattedMessage
id='update_outgoing_webhook.confirm'
defaultMessage='Edit Outgoing Webhook'
/>
);
const confirmMessage = (
<FormattedMessage
id='update_outgoing_webhook.question'
defaultMessage='Your changes may break the existing outgoing webhook. Are you sure you would like to update it?'
/>
);
return (
<ConfirmModal
title={confirmTitle}
message={confirmMessage}
confirmButtonText={confirmButton}
show={this.state.showConfirmModal}
onConfirm={this.submitHook}
onCancel={this.confirmModalDismissed}
/>
);
}
render(): JSX.Element {
if (!this.props.hook) {
return <LoadingScreen/>;
}
return (
<AbstractOutgoingWebhook
team={this.props.team}
header={HEADER}
footer={FOOTER}
loading={LOADING}
renderExtra={this.renderExtra()}
action={this.editOutgoingHook}
serverError={this.state.serverError}
initialHook={this.props.hook}
enablePostUsernameOverride={this.props.enablePostUsernameOverride}
enablePostIconOverride={this.props.enablePostIconOverride}
/>
);
}
}

Просмотреть файл

@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {getOutgoingHook, updateOutgoingHook} from 'mattermost-redux/actions/integrations';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {GlobalState} from '@mattermost/types/store';
import {ActionFunc, GenericAction} from 'mattermost-redux/types/actions';
import {OutgoingWebhook} from '@mattermost/types/integrations';
import {ServerError} from '@mattermost/types/errors';
import EditOutgoingWebhook from './edit_outgoing_webhook';
type OwnProps = {
location: {
search: string | string[][] | Record<string, string> | URLSearchParams | undefined;
};
}
type Actions = {
updateOutgoingHook: (hook: OutgoingWebhook) => Promise<{ data: OutgoingWebhook; error: ServerError }>;
getOutgoingHook: (hookId: string) => Promise<{ data: OutgoingWebhook; error: ServerError }>;
}
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const config = getConfig(state);
const hookId = (new URLSearchParams(ownProps.location.search)).get('id');
const enableOutgoingWebhooks = config.EnableOutgoingWebhooks === 'true';
const enablePostUsernameOverride = config.EnablePostUsernameOverride === 'true';
const enablePostIconOverride = config.EnablePostIconOverride === 'true';
return {
hookId: hookId!,
hook: state.entities.integrations.outgoingHooks[hookId!],
enableOutgoingWebhooks,
enablePostUsernameOverride,
enablePostIconOverride,
};
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
updateOutgoingHook,
getOutgoingHook,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(EditOutgoingWebhook);

Просмотреть файл

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {GlobalState} from 'types/store';
import Integrations from './integrations';
function mapStateToProps(state: GlobalState) {
const config = getConfig(state);
const siteName = config.SiteName;
const enableIncomingWebhooks = config.EnableIncomingWebhooks === 'true';
const enableOutgoingWebhooks = config.EnableOutgoingWebhooks === 'true';
const enableCommands = config.EnableCommands === 'true';
const enableOAuthServiceProvider = config.EnableOAuthServiceProvider === 'true';
return {
siteName,
enableIncomingWebhooks,
enableOutgoingWebhooks,
enableCommands,
enableOAuthServiceProvider,
};
}
export default connect(mapStateToProps)(Integrations);

Просмотреть файл

@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import InstalledCommand from 'components/integrations/installed_command';
import {TestHelper} from '../../utils/test_helper';
describe('components/integrations/InstalledCommand', () => {
const team = TestHelper.getTeamMock({name: 'team_name'});
const command = TestHelper.getCommandMock({
id: 'r5tpgt4iepf45jt768jz84djic',
display_name: 'display_name',
description: 'description',
trigger: 'trigger',
auto_complete: true,
auto_complete_hint: 'auto_complete_hint',
token: 'testToken',
create_at: 1499722850203,
});
const creator = TestHelper.getUserMock({username: 'username'});
const requiredProps = {
team,
command,
onRegenToken: jest.fn(),
onDelete: jest.fn(),
creator,
canChange: false,
};
test('should match snapshot', () => {
const wrapper = shallow(<InstalledCommand {...requiredProps}/>);
expect(wrapper).toMatchSnapshot();
const trigger = `- /${command.trigger} ${command.auto_complete_hint}`;
expect(wrapper.find('.item-details__trigger').text()).toBe(trigger);
expect(wrapper.find('.item-details__name').text()).toBe(command.display_name);
expect(wrapper.find('.item-details__description').text()).toBe(command.description);
});
test('should match snapshot, not autocomplete, no display_name/description/auto_complete_hint', () => {
const minCommand = TestHelper.getCommandMock({
id: 'r5tpgt4iepf45jt768jz84djic',
trigger: 'trigger',
auto_complete: false,
token: 'testToken',
create_at: 1499722850203,
});
const props = {...requiredProps, command: minCommand};
const wrapper = shallow(<InstalledCommand {...props}/>);
expect(wrapper).toMatchSnapshot();
const trigger = `- /${command.trigger}`;
expect(wrapper.find('.item-details__trigger').text()).toBe(trigger);
});
test('should call onRegenToken function', () => {
const onRegenToken = jest.fn();
const canChange = true;
const props = {...requiredProps, onRegenToken, canChange};
const wrapper = shallow(<InstalledCommand {...props}/>);
expect(wrapper).toMatchSnapshot();
wrapper.find('div.item-actions button').first().simulate('click', {preventDefault: jest.fn()});
expect(onRegenToken).toHaveBeenCalledTimes(1);
expect(onRegenToken).toHaveBeenCalledWith(props.command);
});
test('should call onDelete function', () => {
const onDelete = jest.fn();
const canChange = true;
const props = {...requiredProps, onDelete, canChange};
const wrapper = shallow<InstalledCommand>(<InstalledCommand {...props}/>);
expect(wrapper).toMatchSnapshot();
wrapper.instance().handleDelete();
expect(onDelete).toHaveBeenCalledTimes(1);
expect(onDelete).toHaveBeenCalledWith(props.command);
});
test('should filter out command', () => {
const filter = 'no_match';
const props = {...requiredProps, filter};
const wrapper = shallow(<InstalledCommand {...props}/>);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@@ -0,0 +1,193 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {Link} from 'react-router-dom';
import CopyText from 'components/copy_text';
import {Command} from '@mattermost/types/integrations';
import {Team} from '@mattermost/types/teams';
import {UserProfile} from '@mattermost/types/users';
import DeleteIntegrationLink from './delete_integration_link';
type Props = {
/**
* The team data
*/
team: Team;
/**
* Installed slash command to display
*/
command: Command;
/**
* The function to call when Regenerate Token link is clicked
*/
onRegenToken: (command: Command) => void ;
/**
* The function to call when Delete link is clicked
*/
onDelete: (command: Command) => void ;
/**
* Set to filter command, comes from BackstageList
*/
filter?: string;
/**
* The creator user data
*/
creator: UserProfile;
/**
* Set to show edit link
*/
canChange: boolean;
}
export function matchesFilter(command: Command, filter?: string) {
if (!filter) {
return true;
}
return command.display_name.toLowerCase().indexOf(filter) !== -1 ||
command.description.toLowerCase().indexOf(filter) !== -1 ||
command.trigger.toLowerCase().indexOf(filter) !== -1;
}
export default class InstalledCommand extends React.PureComponent<Props> {
handleRegenToken = (e: React.MouseEvent) => {
e.preventDefault();
this.props.onRegenToken(this.props.command);
}
handleDelete = () => {
this.props.onDelete(this.props.command);
}
render() {
const command = this.props.command;
const filter = this.props.filter ? this.props.filter.toLowerCase() : '';
if (!matchesFilter(command, filter)) {
return null;
}
let name;
if (command.display_name) {
name = command.display_name;
} else {
name = (
<FormattedMessage
id='installed_commands.unnamed_command'
defaultMessage='Unnamed Slash Command'
/>
);
}
let description = null;
if (command.description) {
description = (
<div className='item-details__row'>
<span className='item-details__description'>
{command.description}
</span>
</div>
);
}
let trigger = '- /' + command.trigger;
if (command.auto_complete && command.auto_complete_hint) {
trigger += ' ' + command.auto_complete_hint;
}
let actions = null;
if (this.props.canChange) {
actions = (
<div className='item-actions'>
<button
className='style--none color--link'
onClick={this.handleRegenToken}
>
<FormattedMessage
id='installed_integrations.regenToken'
defaultMessage='Regenerate Token'
/>
</button>
{' - '}
<Link to={`/${this.props.team.name}/integrations/commands/edit?id=${command.id}`}>
<FormattedMessage
id='installed_integrations.edit'
defaultMessage='Edit'
/>
</Link>
{' - '}
<DeleteIntegrationLink
modalMessage={
<FormattedMessage
id='installed_commands.delete.confirm'
defaultMessage='This action permanently deletes the slash command and breaks any integrations using it. Are you sure you want to delete it?'
/>
}
onDelete={this.handleDelete}
/>
</div>
);
}
const commandToken = command.token;
return (
<div className='backstage-list__item'>
<div className='item-details'>
<div className='item-details__row d-flex flex-column flex-md-row justify-content-between'>
<div>
<strong className='item-details__name'>
{name}
</strong>
<span className='item-details__trigger'>
{trigger}
</span>
</div>
{actions}
</div>
{description}
<div className='item-details__row'>
<span className='item-details__token'>
<FormattedMessage
id='installed_integrations.token'
defaultMessage='Token: {token}'
values={{
token: commandToken,
}}
/>
<CopyText
value={commandToken}
/>
</span>
</div>
<div className='item-details__row'>
<span className='item-details__creation'>
<FormattedMessage
id='installed_integrations.creation'
defaultMessage='Created by {creator} on {createAt, date, full}'
values={{
creator: this.props.creator.username,
createAt: command.create_at,
}}
/>
</span>
</div>
</div>
</div>
);
}
}

Просмотреть файл

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux';
import {deleteCommand, regenCommandToken} from 'mattermost-redux/actions/integrations';
import {haveITeamPermission} from 'mattermost-redux/selectors/entities/roles';
import {Permissions} from 'mattermost-redux/constants';
import {GenericAction, ActionResult, ActionFunc} from 'mattermost-redux/types/actions';
import {GlobalState} from '@mattermost/types/store';
import InstalledCommands from './installed_commands';
type Props = {
team: {
id: string;
};
}
type Actions = {
regenCommandToken: (id: string) => Promise<ActionResult>;
deleteCommand: (id: string) => Promise<ActionResult>;
}
function mapStateToProps(state: GlobalState, ownProps: Props) {
const canManageOthersSlashCommands = haveITeamPermission(state, ownProps.team.id, Permissions.MANAGE_OTHERS_SLASH_COMMANDS);
return {
canManageOthersSlashCommands,
};
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
regenCommandToken,
deleteCommand,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(InstalledCommands);

Просмотреть файл

@@ -0,0 +1,151 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {ActionResult} from 'mattermost-redux/types/actions';
import * as Utils from 'utils/utils';
import BackstageList from 'components/backstage/components/backstage_list';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import {Team} from '@mattermost/types/teams';
import {UserProfile} from '@mattermost/types/users';
import {RelationOneToOne} from '@mattermost/types/utilities';
import {Command} from '@mattermost/types/integrations';
import InstalledCommand, {matchesFilter} from '../installed_command';
import ExternalLink from 'components/external_link';
type Props = {
team: Team;
user: UserProfile;
users: RelationOneToOne<UserProfile, UserProfile>;
// Installed slash commands to display
commands: Command[];
loading: boolean;
// Set to allow changes to installed slash commands
canManageOthersSlashCommands: boolean;
actions: {
// The function to call when Regenerate Token link is clicked
regenCommandToken: (id: string) => Promise<ActionResult>;
// The function to call when Delete link is clicked
deleteCommand: (id: string) => Promise<ActionResult>;
};
}
export default class InstalledCommands extends React.PureComponent<Props> {
public regenCommandToken = (command: Command): void => {
this.props.actions.regenCommandToken(command.id);
}
public deleteCommand = (command: Command): void => {
this.props.actions.deleteCommand(command.id);
}
private commandCompare(a: Command, b: Command) {
let nameA = a.display_name;
if (!nameA) {
nameA = Utils.localizeMessage('installed_commands.unnamed_command', 'Unnamed Slash Command');
}
let nameB = b.display_name;
if (!nameB) {
nameB = Utils.localizeMessage('installed_commands.unnamed_command', 'Unnamed Slash Command');
}
return nameA.localeCompare(nameB);
}
public render(): JSX.Element {
const commands = (filter: string) => this.props.commands.
filter((command) => command.team_id === this.props.team.id).
filter((command) => matchesFilter(command, filter)).
sort(this.commandCompare).map((command) => {
const canChange = this.props.canManageOthersSlashCommands || this.props.user.id === command.creator_id;
return (
<InstalledCommand
key={command.id}
team={this.props.team}
command={command}
onRegenToken={this.regenCommandToken}
onDelete={this.deleteCommand}
creator={this.props.users[command.creator_id] || {}}
canChange={canChange}
/>
);
});
return (
<BackstageList
header={
<FormattedMessage
id='installed_commands.header'
defaultMessage='Installed Slash Commands'
/>
}
addText={
<FormattedMessage
id='installed_commands.add'
defaultMessage='Add Slash Command'
/>
}
addLink={'/' + this.props.team.name + '/integrations/commands/add'}
addButtonId='addSlashCommand'
emptyText={
<FormattedMessage
id='installed_commands.empty'
defaultMessage='No slash commands found'
/>
}
emptyTextSearch={
<FormattedMarkdownMessage
id='installed_commands.emptySearch'
defaultMessage='No slash commands match {searchTerm}'
/>
}
helpText={
<FormattedMessage
id='installed_commands.help'
defaultMessage='Use slash commands to connect external tools to Mattermost. {buildYourOwn} or visit the {appDirectory} to find self-hosted, third-party apps and integrations.'
values={{
buildYourOwn: (
<ExternalLink
href='https://developers.mattermost.com/integrate/admin-guide/admin-slash-commands/'
location='installed_commands'
>
<FormattedMessage
id='installed_commands.help.buildYourOwn'
defaultMessage='Build Your Own'
/>
</ExternalLink>
),
appDirectory: (
<ExternalLink
href='https://mattermost.com/marketplace'
location='installed_commands'
>
<FormattedMessage
id='installed_commands.help.appDirectory'
defaultMessage='App Directory'
/>
</ExternalLink>
),
}}
/>
}
searchPlaceholder={Utils.localizeMessage('installed_commands.search', 'Search Slash Commands')}
loading={this.props.loading}
>
{(filter: string) => {
const children = commands(filter);
return [children, children.length > 0];
}}
</BackstageList>
);
}
}

Просмотреть файл

@@ -0,0 +1,376 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Link} from 'react-router-dom';
import {shallow} from 'enzyme';
import DeleteIntegrationLink from 'components/integrations/delete_integration_link';
import InstalledIncomingWebhook from 'components/integrations/installed_incoming_webhook';
import {IncomingWebhook} from '@mattermost/types/integrations';
describe('components/integrations/InstalledIncomingWebhook', () => {
const incomingWebhook: IncomingWebhook = {
id: '9w96t4nhbfdiij64wfqors4i1r',
channel_id: '1jiw9kphbjrntfyrm7xpdcya4o',
create_at: 1502455422406,
delete_at: 0,
description: 'build status',
display_name: 'build',
team_id: 'eatxocwc3bg9ffo9xyybnj4omr',
update_at: 1502455422406,
user_id: 'zaktnt8bpbgu8mb6ez9k64r7sa',
username: 'username',
icon_url: 'http://test/icon.png',
channel_locked: false,
};
const teamId = 'testteamid';
test('should match snapshot', () => {
function emptyFunction() {} //eslint-disable-line no-empty-function
const wrapper = shallow<InstalledIncomingWebhook>(
<InstalledIncomingWebhook
key={1}
incomingWebhook={incomingWebhook}
onDelete={emptyFunction}
creator={{username: 'creator'}}
canChange={true}
team={{
id: teamId,
name: 'test',
create_at: 1502455422406,
delete_at: 0,
update_at: 1502455422406,
type: 'O',
display_name: 'name',
scheme_id: 'id',
allow_open_invite: false,
group_constrained: false,
description: '',
email: '',
company_name: '',
allowed_domains: '',
invite_id: '',
}}
channel={{
id: '1jiw9kphbjrntfyrm7xpdcya4o',
name: 'town-square',
create_at: 1502455422406,
delete_at: 0,
update_at: 1502455422406,
team_id: teamId,
type: 'O',
display_name: 'name',
header: 'header',
purpose: 'purpose',
last_post_at: 0,
last_root_post_at: 0,
creator_id: 'id',
scheme_id: 'id',
group_constrained: false,
}}
/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should not have edit and delete actions if user does not have permissions to change', () => {
function emptyFunction() {} //eslint-disable-line no-empty-function
const wrapper = shallow<InstalledIncomingWebhook>(
<InstalledIncomingWebhook
key={1}
incomingWebhook={incomingWebhook}
onDelete={emptyFunction}
creator={{username: 'creator'}}
canChange={false}
team={{
id: teamId,
name: 'test',
create_at: 1502455422406,
delete_at: 0,
update_at: 1502455422406,
type: 'O',
display_name: 'name',
scheme_id: 'id',
allow_open_invite: false,
group_constrained: false,
description: '',
email: '',
company_name: '',
allowed_domains: '',
invite_id: '',
}}
channel={{
id: '1jiw9kphbjrntfyrm7xpdcya4o',
name: 'town-square',
create_at: 1502455422406,
delete_at: 0,
update_at: 1502455422406,
team_id: teamId,
type: 'O',
display_name: 'name',
header: 'header',
purpose: 'purpose',
last_post_at: 0,
last_root_post_at: 0,
creator_id: 'id',
scheme_id: 'id',
group_constrained: false,
}}
/>,
);
expect(wrapper.find('.item-actions').length).toBe(0);
});
test('should have edit and delete actions if user can change webhook', () => {
function emptyFunction() {} //eslint-disable-line no-empty-function
const wrapper = shallow<InstalledIncomingWebhook>(
<InstalledIncomingWebhook
key={1}
incomingWebhook={incomingWebhook}
onDelete={emptyFunction}
creator={{username: 'creator'}}
canChange={true}
team={{
id: teamId,
name: 'test',
create_at: 1502455422406,
delete_at: 0,
update_at: 1502455422406,
type: 'O',
display_name: 'name',
scheme_id: 'id',
allow_open_invite: false,
group_constrained: false,
description: '',
email: '',
company_name: '',
allowed_domains: '',
invite_id: '',
}}
channel={{
id: '1jiw9kphbjrntfyrm7xpdcya4o',
name: 'town-square',
create_at: 1502455422406,
delete_at: 0,
update_at: 1502455422406,
team_id: teamId,
type: 'O',
display_name: 'name',
header: 'header',
purpose: 'purpose',
last_post_at: 0,
last_root_post_at: 0,
creator_id: 'id',
scheme_id: 'id',
group_constrained: false,
}}
/>,
);
expect(wrapper.find('.item-actions').find(Link).exists()).toBe(true);
expect(wrapper.find('.item-actions').find(DeleteIntegrationLink).exists()).toBe(true);
});
test('Should have the same name and description on view as it has in incomingWebhook', () => {
function emptyFunction() {} //eslint-disable-line no-empty-function
const wrapper = shallow<InstalledIncomingWebhook>(
<InstalledIncomingWebhook
key={1}
incomingWebhook={incomingWebhook}
onDelete={emptyFunction}
creator={{username: 'creator'}}
canChange={false}
team={{
id: teamId,
name: 'test',
create_at: 1502455422406,
delete_at: 0,
update_at: 1502455422406,
type: 'O',
display_name: 'name',
scheme_id: 'id',
allow_open_invite: false,
group_constrained: false,
description: '',
email: '',
company_name: '',
allowed_domains: '',
invite_id: '',
}}
channel={{
id: '1jiw9kphbjrntfyrm7xpdcya4o',
name: 'town-square',
create_at: 1502455422406,
delete_at: 0,
update_at: 1502455422406,
team_id: teamId,
type: 'O',
display_name: 'name',
header: 'header',
purpose: 'purpose',
last_post_at: 0,
last_root_post_at: 0,
creator_id: 'id',
scheme_id: 'id',
group_constrained: false,
}}
/>,
);
expect(wrapper.find('.item-details__description').text()).toBe('build status');
expect(wrapper.find('.item-details__name').text()).toBe('build');
});
test('Should not display description as it is null', () => {
function emptyFunction() {} //eslint-disable-line no-empty-function
const newIncomingWebhook: IncomingWebhook = {...incomingWebhook, description: ''};
const wrapper = shallow<InstalledIncomingWebhook>(
<InstalledIncomingWebhook
key={1}
incomingWebhook={newIncomingWebhook}
onDelete={emptyFunction}
creator={{username: 'creator'}}
canChange={false}
team={{
id: teamId,
name: 'test',
create_at: 1502455422406,
delete_at: 0,
update_at: 1502455422406,
type: 'O',
display_name: 'name',
scheme_id: 'id',
allow_open_invite: false,
group_constrained: false,
description: '',
email: '',
company_name: '',
allowed_domains: '',
invite_id: '',
}}
channel={{
id: '1jiw9kphbjrntfyrm7xpdcya4o',
name: 'town-square',
create_at: 1502455422406,
delete_at: 0,
update_at: 1502455422406,
team_id: teamId,
type: 'O',
display_name: 'name',
header: 'header',
purpose: 'purpose',
last_post_at: 0,
last_root_post_at: 0,
creator_id: 'id',
scheme_id: 'id',
group_constrained: false,
}}
/>,
);
expect(wrapper.find('.item-details__description').length).toBe(0);
});
test('Should not render any nodes as there are no filtered results', () => {
function emptyFunction() {} //eslint-disable-line no-empty-function
const wrapper = shallow<InstalledIncomingWebhook>(
<InstalledIncomingWebhook
key={1}
incomingWebhook={incomingWebhook}
onDelete={emptyFunction}
creator={{username: 'creator'}}
filter={'someLongText'}
canChange={false}
team={{
id: teamId,
name: 'test',
create_at: 1502455422406,
delete_at: 0,
update_at: 1502455422406,
type: 'O',
display_name: 'name',
scheme_id: 'id',
allow_open_invite: false,
group_constrained: false,
description: '',
email: '',
company_name: '',
allowed_domains: '',
invite_id: '',
}}
channel={{
id: '1jiw9kphbjrntfyrm7xpdcya4o',
name: 'town-square',
create_at: 1502455422406,
delete_at: 0,
update_at: 1502455422406,
team_id: teamId,
type: 'O',
display_name: 'name',
header: 'header',
purpose: 'purpose',
last_post_at: 0,
last_root_post_at: 0,
creator_id: 'id',
scheme_id: 'id',
group_constrained: false,
}}
/>,
);
expect(wrapper.getElement()).toBe(null);
});
test('Should render a webhook item as filtered result is true', () => {
function emptyFunction() {} //eslint-disable-line no-empty-function
const wrapper = shallow<InstalledIncomingWebhook>(
<InstalledIncomingWebhook
key={1}
incomingWebhook={incomingWebhook}
onDelete={emptyFunction}
creator={{username: 'creator'}}
filter={'buil'}
canChange={true}
team={{
id: teamId,
name: 'test',
create_at: 1502455422406,
delete_at: 0,
update_at: 1502455422406,
type: 'O',
display_name: 'name',
scheme_id: 'id',
allow_open_invite: false,
group_constrained: false,
description: '',
email: '',
company_name: '',
allowed_domains: '',
invite_id: '',
}}
channel={{
id: '1jiw9kphbjrntfyrm7xpdcya4o',
name: 'town-square',
create_at: 1502455422406,
delete_at: 0,
update_at: 1502455422406,
team_id: teamId,
type: 'O',
display_name: 'name',
header: 'header',
purpose: 'purpose',
last_post_at: 0,
last_root_post_at: 0,
creator_id: 'id',
scheme_id: 'id',
group_constrained: false,
}}
/>,
);
expect(wrapper.find('.item-details').exists()).toBe(true);
});
});

Просмотреть файл

@@ -0,0 +1,184 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {Link} from 'react-router-dom';
import {getSiteURL} from 'utils/url';
import CopyText from 'components/copy_text';
import {IncomingWebhook} from '@mattermost/types/integrations';
import {Team} from '@mattermost/types/teams';
import {Channel} from '@mattermost/types/channels';
import DeleteIntegrationLink from './delete_integration_link';
export function matchesFilter(incomingWebhook: IncomingWebhook, channel: Channel, filter: string) {
if (!filter) {
return true;
}
if (incomingWebhook.display_name.toLowerCase().indexOf(filter) !== -1 ||
incomingWebhook.description.toLowerCase().indexOf(filter) !== -1) {
return true;
}
if (incomingWebhook.channel_id) {
if (channel && channel.name.toLowerCase().indexOf(filter) !== -1) {
return true;
}
}
return false;
}
type Props = {
/**
* Data used for showing webhook details
*/
incomingWebhook: IncomingWebhook;
/**
* Function to call when webhook delete button is pressed
*/
onDelete: (incomingWebhook: IncomingWebhook) => void;
/**
* String used for filtering webhook item
*/
filter?: string;
/**
* Data used for showing created by details
*/
creator: {
username: string;
};
/**
* Set to show available actions on webhook
*/
canChange: boolean;
/**
* Data used in routing of webhook for modifications
*/
team: Team;
/**
* Data used for filtering of webhook based on filter prop
*/
channel: Channel;
}
export default class InstalledIncomingWebhook extends React.PureComponent<Props> {
handleDelete = () => {
this.props.onDelete(this.props.incomingWebhook);
}
render() {
const incomingWebhook = this.props.incomingWebhook;
const channel = this.props.channel;
const filter = this.props.filter ? this.props.filter.toLowerCase() : '';
if (!matchesFilter(incomingWebhook, channel, filter)) {
return null;
}
let displayName;
if (incomingWebhook.display_name) {
displayName = incomingWebhook.display_name;
} else if (channel) {
displayName = channel.display_name;
} else {
displayName = (
<FormattedMessage
id='installed_incoming_webhooks.unknown_channel'
defaultMessage='A Private Webhook'
/>
);
}
let description = null;
if (incomingWebhook.description) {
description = (
<div className='item-details__row'>
<span className='item-details__description'>
{incomingWebhook.description}
</span>
</div>
);
}
let actions = null;
if (this.props.canChange) {
actions = (
<div className='item-actions'>
<Link to={`/${this.props.team.name}/integrations/incoming_webhooks/edit?id=${incomingWebhook.id}`}>
<FormattedMessage
id='installed_integrations.edit'
defaultMessage='Edit'
/>
</Link>
{' - '}
<DeleteIntegrationLink
modalMessage={
<FormattedMessage
id='installed_incoming_webhooks.delete.confirm'
defaultMessage='This action permanently deletes the incoming webhook and breaks any integrations using it. Are you sure you want to delete it?'
/>
}
onDelete={this.handleDelete}
/>
</div>
);
}
const incomingWebhookId = getSiteURL() + '/hooks/' + incomingWebhook.id;
return (
<div className='backstage-list__item'>
<div className='item-details'>
<div className='item-details__row d-flex flex-column flex-md-row justify-content-between'>
<strong className='item-details__name'>
{displayName}
</strong>
{actions}
</div>
{description}
<div className='item-details__row'>
<span className='item-details__url word-break--all'>
<FormattedMessage
id='installed_integrations.url'
defaultMessage='URL: {url}'
values={{
url: incomingWebhookId,
}}
/>
<span>
<CopyText
value={incomingWebhookId}
/>
</span>
</span>
</div>
<div className='item-details__row'>
<span className='item-details__creation'>
<FormattedMessage
id='installed_integrations.creation'
defaultMessage='Created by {creator} on {createAt, date, full}'
values={{
creator: this.props.creator.username,
createAt: incomingWebhook.create_at,
}}
/>
</span>
</div>
</div>
</div>
);
}
}

Просмотреть файл

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {removeIncomingHook} from 'mattermost-redux/actions/integrations';
import {getAllChannels} from 'mattermost-redux/selectors/entities/channels';
import {getIncomingHooks} from 'mattermost-redux/selectors/entities/integrations';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getUsers} from 'mattermost-redux/selectors/entities/users';
import {GlobalState} from '@mattermost/types/store';
import {haveITeamPermission} from 'mattermost-redux/selectors/entities/roles';
import {Permissions} from 'mattermost-redux/constants';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {ActionResult, GenericAction} from 'mattermost-redux/types/actions';
import {loadIncomingHooksAndProfilesForTeam} from 'actions/integration_actions.jsx';
import InstalledIncomingWebhooks from './installed_incoming_webhooks';
type Actions = {
removeIncomingHook: (hookId: string) => Promise<ActionResult>;
loadIncomingHooksAndProfilesForTeam: (teamId: string, startPageNumber: number, pageSize: string) => Promise<ActionResult>;
}
function mapStateToProps(state: GlobalState) {
const config = getConfig(state);
const teamId = getCurrentTeamId(state);
const canManageOthersWebhooks = haveITeamPermission(state, teamId, Permissions.MANAGE_OTHERS_INCOMING_WEBHOOKS);
const incomingHooks = getIncomingHooks(state);
const incomingWebhooks = Object.keys(incomingHooks).
map((key) => incomingHooks[key]).
filter((incomingWebhook) => incomingWebhook.team_id === teamId);
const enableIncomingWebhooks = config.EnableIncomingWebhooks === 'true';
return {
incomingWebhooks,
channels: getAllChannels(state),
users: getUsers(state),
canManageOthersWebhooks,
enableIncomingWebhooks,
};
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<any>, Actions>({
loadIncomingHooksAndProfilesForTeam,
removeIncomingHook,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(InstalledIncomingWebhooks);

Просмотреть файл

@@ -0,0 +1,171 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import InstalledIncomingWebhook, {matchesFilter} from 'components/integrations/installed_incoming_webhook';
import {Team} from '@mattermost/types/teams';
import {Channel} from '@mattermost/types/channels';
import {IncomingWebhook} from '@mattermost/types/integrations';
import {ActionResult} from 'mattermost-redux/types/actions';
import {UserProfile} from '@mattermost/types/users';
import {IDMappedObjects} from '@mattermost/types/utilities';
import BackstageList from 'components/backstage/components/backstage_list';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import Constants from 'utils/constants';
import * as Utils from 'utils/utils';
import ExternalLink from 'components/external_link';
type Props = {
team: Team;
user: UserProfile;
canManageOthersWebhooks: boolean;
incomingWebhooks: IncomingWebhook[];
channels: IDMappedObjects<Channel>;
users: IDMappedObjects<UserProfile>;
enableIncomingWebhooks: boolean;
actions: {
removeIncomingHook: (hookId: string) => Promise<ActionResult>;
loadIncomingHooksAndProfilesForTeam: (teamId: string, startPageNumber: number,
pageSize: string) => Promise<ActionResult>;
};
}
type State = {
loading: boolean;
}
export default class InstalledIncomingWebhooks extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loading: true,
};
}
componentDidMount() {
if (this.props.enableIncomingWebhooks) {
this.props.actions.loadIncomingHooksAndProfilesForTeam(
this.props.team.id,
Constants.Integrations.START_PAGE_NUM,
Constants.Integrations.PAGE_SIZE,
).then(
() => this.setState({loading: false}),
);
}
}
deleteIncomingWebhook = (incomingWebhook: IncomingWebhook) => {
this.props.actions.removeIncomingHook(incomingWebhook.id);
}
incomingWebhookCompare = (a: IncomingWebhook, b: IncomingWebhook) => {
let displayNameA = a.display_name;
if (!displayNameA) {
const channelA = this.props.channels[a.channel_id];
if (channelA) {
displayNameA = channelA.display_name;
} else {
displayNameA = Utils.localizeMessage('installed_incoming_webhooks.unknown_channel', 'A Private Webhook');
}
}
const displayNameB = b.display_name;
return displayNameA.localeCompare(displayNameB);
}
incomingWebhooks = (filter: string) => this.props.incomingWebhooks.
sort(this.incomingWebhookCompare).
filter((incomingWebhook: IncomingWebhook) => matchesFilter(incomingWebhook, this.props.channels[incomingWebhook.channel_id], filter)).
map((incomingWebhook: IncomingWebhook) => {
const canChange = this.props.canManageOthersWebhooks || this.props.user.id === incomingWebhook.user_id;
const channel = this.props.channels[incomingWebhook.channel_id];
return (
<InstalledIncomingWebhook
key={incomingWebhook.id}
incomingWebhook={incomingWebhook}
onDelete={this.deleteIncomingWebhook}
creator={this.props.users[incomingWebhook.user_id] || {}}
canChange={canChange}
team={this.props.team}
channel={channel}
/>
);
});
render() {
return (
<BackstageList
header={
<FormattedMessage
id='installed_incoming_webhooks.header'
defaultMessage='Installed Incoming Webhooks'
/>
}
addText={
<FormattedMessage
id='installed_incoming_webhooks.add'
defaultMessage='Add Incoming Webhook'
/>
}
addLink={'/' + this.props.team.name + '/integrations/incoming_webhooks/add'}
addButtonId='addIncomingWebhook'
emptyText={
<FormattedMessage
id='installed_incoming_webhooks.empty'
defaultMessage='No incoming webhooks found'
/>
}
emptyTextSearch={
<FormattedMarkdownMessage
id='installed_incoming_webhooks.emptySearch'
defaultMessage='No incoming webhooks match {searchTerm}'
/>
}
helpText={
<FormattedMessage
id='installed_incoming_webhooks.help'
defaultMessage='Use incoming webhooks to connect external tools to Mattermost. {buildYourOwn} or visit the {appDirectory} to find self-hosted, third-party apps and integrations.'
values={{
buildYourOwn: (
<ExternalLink
location='installed_incoming_webhooks'
href='https://developers.mattermost.com/integrate/admin-guide/admin-webhooks-incoming/'
>
<FormattedMessage
id='installed_incoming_webhooks.help.buildYourOwn'
defaultMessage='Build Your Own'
/>
</ExternalLink>
),
appDirectory: (
<ExternalLink
href='https://mattermost.com/marketplace'
location='installed_incoming_webhooks'
>
<FormattedMessage
id='installed_incoming_webhooks.help.appDirectory'
defaultMessage='App Directory'
/>
</ExternalLink>
),
}}
/>
}
searchPlaceholder={Utils.localizeMessage('installed_incoming_webhooks.search', 'Search Incoming Webhooks')}
loading={this.state.loading}
>
{(filter: string) => {
const children = this.incomingWebhooks(filter);
return [children, children.length > 0];
}}
</BackstageList>
);
}
}

Просмотреть файл

@@ -0,0 +1,578 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/InstalledOAuthApp should filter out OAuthApp 1`] = `""`;
exports[`components/integrations/InstalledOAuthApp should match snapshot 1`] = `
<div
className="backstage-list__item"
>
<div
className="integration__icon integration-list__icon"
>
<img
alt="get app screenshot"
src="https://test.com/icon"
/>
</div>
<div
className="item-details"
>
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<strong
className="item-details__name"
>
testApp
</strong>
<div
className="item-actions"
>
<button
className="style--none color--link"
id="showSecretButton"
onClick={[Function]}
>
<MemoizedFormattedMessage
defaultMessage="Show Secret"
id="installed_integrations.showSecret"
/>
</button>
-
<button
className="style--none color--link"
id="regenerateSecretButton"
onClick={[Function]}
>
<MemoizedFormattedMessage
defaultMessage="Regenerate Secret"
id="installed_integrations.regenSecret"
/>
</button>
-
<Link
to="/team_name/integrations/oauth2-apps/edit?id=facxd9wpzpbpfp8pad78xj75pr"
>
<MemoizedFormattedMessage
defaultMessage="Edit"
id="installed_integrations.edit"
/>
</Link>
-
<Connect(DeleteIntegrationLink)
modalMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="This action permanently deletes the OAuth 2.0 application and breaks any integrations using it. Are you sure you want to delete it?"
id="installed_oauth_apps.delete.confirm"
/>
}
onDelete={[Function]}
/>
</div>
</div>
<div
className="item-details__row"
>
<span
className="item-details__description"
>
testing
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__url word-break--all"
>
<FormattedMarkdownMessage
defaultMessage="Is Trusted: **{isTrusted}**"
id="installed_oauth_apps.is_trusted"
values={
Object {
"isTrusted": "Yes",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__token"
>
<FormattedMarkdownMessage
defaultMessage="Client ID: **{clientId}**"
id="installed_integrations.client_id"
values={
Object {
"clientId": "facxd9wpzpbpfp8pad78xj75pr",
}
}
/>
<CopyText
defaultMessage="Copy Client Id"
idMessage="integrations.copy_client_id"
value="facxd9wpzpbpfp8pad78xj75pr"
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__token"
>
<MemoizedFormattedMessage
defaultMessage="Client Secret: **{clientSecret}**"
id="installed_integrations.client_secret"
values={
Object {
"clientSecret": "***************",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__url word-break--all"
>
<MemoizedFormattedMessage
defaultMessage="Callback URLs: {urls}"
id="installed_integrations.callback_urls"
values={
Object {
"urls": "https://test.com/callback, https://test.com/callback2",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__creation"
>
<MemoizedFormattedMessage
defaultMessage="Created by {creator} on {createAt, date, full}"
id="installed_integrations.creation"
values={
Object {
"createAt": 1501365458934,
"creator": "somename",
}
}
/>
</span>
</div>
</div>
</div>
`;
exports[`components/integrations/InstalledOAuthApp should match snapshot from app 1`] = `
<div
className="backstage-list__item"
>
<div
className="integration__icon integration-list__icon"
>
<img
alt="get app screenshot"
src="https://test.com/icon"
/>
</div>
<div
className="item-details"
>
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<strong
className="item-details__name"
>
testApp
</strong>
</div>
<div
className="item-details__row"
>
<span
className="item-details__description"
>
testing
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__creation"
>
<MemoizedFormattedMessage
defaultMessage="Managed by Apps Framework"
id="installed_integrations.fromApp"
/>
</span>
</div>
</div>
</div>
`;
exports[`components/integrations/InstalledOAuthApp should match snapshot, on error 1`] = `
<div
className="backstage-list__item"
>
<div
className="integration__icon integration-list__icon"
>
<img
alt="get app screenshot"
src="https://test.com/icon"
/>
</div>
<div
className="item-details"
>
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<strong
className="item-details__name"
>
<MemoizedFormattedMessage
defaultMessage="Unnamed OAuth 2.0 Application"
id="installed_integrations.unnamed_oauth_app"
/>
</strong>
<div
className="item-actions"
>
<button
className="style--none color--link"
id="showSecretButton"
onClick={[Function]}
>
<MemoizedFormattedMessage
defaultMessage="Show Secret"
id="installed_integrations.showSecret"
/>
</button>
-
<button
className="style--none color--link"
id="regenerateSecretButton"
onClick={[Function]}
>
<MemoizedFormattedMessage
defaultMessage="Regenerate Secret"
id="installed_integrations.regenSecret"
/>
</button>
-
<Link
to="/team_name/integrations/oauth2-apps/edit?id=facxd9wpzpbpfp8pad78xj75pr"
>
<MemoizedFormattedMessage
defaultMessage="Edit"
id="installed_integrations.edit"
/>
</Link>
-
<Connect(DeleteIntegrationLink)
modalMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="This action permanently deletes the OAuth 2.0 application and breaks any integrations using it. Are you sure you want to delete it?"
id="installed_oauth_apps.delete.confirm"
/>
}
onDelete={[Function]}
/>
</div>
</div>
<FormError
error="error"
errors={Array []}
/>
<div
className="item-details__row"
>
<span
className="item-details__description"
>
testing
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__url word-break--all"
>
<FormattedMarkdownMessage
defaultMessage="Is Trusted: **{isTrusted}**"
id="installed_oauth_apps.is_trusted"
values={
Object {
"isTrusted": "No",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__token"
>
<FormattedMarkdownMessage
defaultMessage="Client ID: **{clientId}**"
id="installed_integrations.client_id"
values={
Object {
"clientId": "facxd9wpzpbpfp8pad78xj75pr",
}
}
/>
<CopyText
defaultMessage="Copy Client Id"
idMessage="integrations.copy_client_id"
value="facxd9wpzpbpfp8pad78xj75pr"
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__token"
>
<MemoizedFormattedMessage
defaultMessage="Client Secret: **{clientSecret}**"
id="installed_integrations.client_secret"
values={
Object {
"clientSecret": "***************",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__url word-break--all"
>
<MemoizedFormattedMessage
defaultMessage="Callback URLs: {urls}"
id="installed_integrations.callback_urls"
values={
Object {
"urls": "https://test.com/callback, https://test.com/callback2",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__creation"
>
<MemoizedFormattedMessage
defaultMessage="Created by {creator} on {createAt, date, full}"
id="installed_integrations.creation"
values={
Object {
"createAt": 1501365458934,
"creator": "somename",
}
}
/>
</span>
</div>
</div>
</div>
`;
exports[`components/integrations/InstalledOAuthApp should match snapshot, when oauthApp is without name and not trusted 1`] = `
<div
className="backstage-list__item"
>
<div
className="integration__icon integration-list__icon"
>
<img
alt="get app screenshot"
src="https://test.com/icon"
/>
</div>
<div
className="item-details"
>
<div
className="item-details__row d-flex flex-column flex-md-row justify-content-between"
>
<strong
className="item-details__name"
>
<MemoizedFormattedMessage
defaultMessage="Unnamed OAuth 2.0 Application"
id="installed_integrations.unnamed_oauth_app"
/>
</strong>
<div
className="item-actions"
>
<button
className="style--none color--link"
id="showSecretButton"
onClick={[Function]}
>
<MemoizedFormattedMessage
defaultMessage="Show Secret"
id="installed_integrations.showSecret"
/>
</button>
-
<button
className="style--none color--link"
id="regenerateSecretButton"
onClick={[Function]}
>
<MemoizedFormattedMessage
defaultMessage="Regenerate Secret"
id="installed_integrations.regenSecret"
/>
</button>
-
<Link
to="/team_name/integrations/oauth2-apps/edit?id=facxd9wpzpbpfp8pad78xj75pr"
>
<MemoizedFormattedMessage
defaultMessage="Edit"
id="installed_integrations.edit"
/>
</Link>
-
<Connect(DeleteIntegrationLink)
modalMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="This action permanently deletes the OAuth 2.0 application and breaks any integrations using it. Are you sure you want to delete it?"
id="installed_oauth_apps.delete.confirm"
/>
}
onDelete={[Function]}
/>
</div>
</div>
<div
className="item-details__row"
>
<span
className="item-details__description"
>
testing
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__url word-break--all"
>
<FormattedMarkdownMessage
defaultMessage="Is Trusted: **{isTrusted}**"
id="installed_oauth_apps.is_trusted"
values={
Object {
"isTrusted": "No",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__token"
>
<FormattedMarkdownMessage
defaultMessage="Client ID: **{clientId}**"
id="installed_integrations.client_id"
values={
Object {
"clientId": "facxd9wpzpbpfp8pad78xj75pr",
}
}
/>
<CopyText
defaultMessage="Copy Client Id"
idMessage="integrations.copy_client_id"
value="facxd9wpzpbpfp8pad78xj75pr"
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__token"
>
<MemoizedFormattedMessage
defaultMessage="Client Secret: **{clientSecret}**"
id="installed_integrations.client_secret"
values={
Object {
"clientSecret": "***************",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__url word-break--all"
>
<MemoizedFormattedMessage
defaultMessage="Callback URLs: {urls}"
id="installed_integrations.callback_urls"
values={
Object {
"urls": "https://test.com/callback, https://test.com/callback2",
}
}
/>
</span>
</div>
<div
className="item-details__row"
>
<span
className="item-details__creation"
>
<MemoizedFormattedMessage
defaultMessage="Created by {creator} on {createAt, date, full}"
id="installed_integrations.creation"
values={
Object {
"createAt": 1501365458934,
"creator": "somename",
}
}
/>
</span>
</div>
</div>
</div>
`;

Просмотреть файл

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {GlobalState} from 'types/store';
import {getDisplayNameByUser} from 'utils/utils';
import InstalledOAuthApp, {InstalledOAuthAppProps} from './installed_oauth_app';
function mapStateToProps(state: GlobalState, ownProps: InstalledOAuthAppProps) {
const oauthApp = ownProps.oauthApp || {};
return {
creatorName: getDisplayNameByUser(state, getUser(state, oauthApp.creator_id)),
};
}
export default connect(mapStateToProps)(InstalledOAuthApp);

Просмотреть файл

@@ -0,0 +1,150 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import DeleteIntegrationLink from 'components/integrations/delete_integration_link';
import InstalledOAuthApp from 'components/integrations/installed_oauth_app/installed_oauth_app';
describe('components/integrations/InstalledOAuthApp', () => {
const FAKE_SECRET = '***************';
const team = {name: 'team_name'};
const oauthApp = {
id: 'facxd9wpzpbpfp8pad78xj75pr',
name: 'testApp',
client_secret: '88cxd9wpzpbpfp8pad78xj75pr',
create_at: 1501365458934,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
description: 'testing',
homepage: 'https://test.com',
icon_url: 'https://test.com/icon',
is_trusted: true,
update_at: 1501365458934,
callback_urls: ['https://test.com/callback', 'https://test.com/callback2'],
};
const regenOAuthAppSecretRequest = {
status: 'not_started',
error: null,
};
const baseProps = {
team,
oauthApp,
creatorName: 'somename',
regenOAuthAppSecretRequest,
onRegenerateSecret: jest.fn(),
onDelete: jest.fn(),
filter: '',
fromApp: false,
};
test('should match snapshot', () => {
const props = {...baseProps, team};
const wrapper = shallow(
<InstalledOAuthApp {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot from app', () => {
const props = {...baseProps, team, fromApp: true};
const wrapper = shallow(
<InstalledOAuthApp {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, when oauthApp is without name and not trusted', () => {
const props = {...baseProps, team};
props.oauthApp.name = '';
props.oauthApp.is_trusted = false;
const wrapper = shallow(
<InstalledOAuthApp {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, on error', () => {
const props = {...baseProps, team};
const wrapper = shallow(
<InstalledOAuthApp {...props}/>,
);
wrapper.setState({error: 'error'});
expect(wrapper).toMatchSnapshot();
});
test('should call onRegenerateSecret function', () => {
const props = {
...baseProps,
team,
};
baseProps.onRegenerateSecret.mockResolvedValue({});
const wrapper = shallow(
<InstalledOAuthApp {...props}/>,
);
wrapper.find('#regenerateSecretButton').simulate('click', {preventDefault: jest.fn()});
expect(baseProps.onRegenerateSecret).toBeCalled();
expect(baseProps.onRegenerateSecret).toHaveBeenCalledWith(oauthApp.id);
});
test('should filter out OAuthApp', () => {
const filter = 'filter';
const props = {...baseProps, filter};
const wrapper = shallow(
<InstalledOAuthApp {...props}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should match state on button clicks, both showSecretButton and hideSecretButton', () => {
const props = {...baseProps, team};
const wrapper = shallow(
<InstalledOAuthApp {...props}/>,
);
expect(wrapper.find('#showSecretButton').exists()).toBe(true);
expect(wrapper.find('#hideSecretButton').exists()).toBe(false);
wrapper.find('#showSecretButton').simulate('click', {preventDefault: jest.fn()});
expect(wrapper.state('clientSecret')).toEqual(oauthApp.client_secret);
expect(wrapper.find('#showSecretButton').exists()).toBe(false);
expect(wrapper.find('#hideSecretButton').exists()).toBe(true);
wrapper.find('#hideSecretButton').simulate('click', {preventDefault: jest.fn()});
expect(wrapper.state('clientSecret')).toEqual(FAKE_SECRET);
expect(wrapper.find('#showSecretButton').exists()).toBe(true);
expect(wrapper.find('#hideSecretButton').exists()).toBe(false);
});
test('should match on handleRegenerate', () => {
const props = {
...baseProps,
team,
};
baseProps.onRegenerateSecret.mockResolvedValue({});
const wrapper = shallow(
<InstalledOAuthApp {...props}/>,
);
expect(wrapper.find('#regenerateSecretButton').exists()).toBe(true);
wrapper.find('#regenerateSecretButton').simulate('click', {preventDefault: jest.fn()});
expect(baseProps.onRegenerateSecret).toBeCalled();
expect(baseProps.onRegenerateSecret).toHaveBeenCalledWith(oauthApp.id);
});
test('should have called props.onDelete on handleDelete ', () => {
const newOnDelete = jest.fn();
const props = {...baseProps, team, onDelete: newOnDelete};
const wrapper = shallow(
<InstalledOAuthApp {...props}/>,
);
expect(wrapper.find(DeleteIntegrationLink).exists()).toBe(true);
wrapper.find(DeleteIntegrationLink).props().onDelete();
expect(newOnDelete).toBeCalled();
expect(newOnDelete).toHaveBeenCalledWith(oauthApp);
});
});

Просмотреть файл

@@ -0,0 +1,354 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {Link} from 'react-router-dom';
import {OAuthApp} from '@mattermost/types/integrations';
import {Team} from '@mattermost/types/teams';
import * as Utils from 'utils/utils';
import FormError from 'components/form_error';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import CopyText from 'components/copy_text';
import DeleteIntegrationLink from '../delete_integration_link';
const FAKE_SECRET = '***************';
export function matchesFilter(oauthApp: OAuthApp, filter?: string | null): boolean {
if (!filter) {
return true;
}
return oauthApp.name.toLowerCase().includes(filter);
}
export type InstalledOAuthAppProps = {
/**
* The team data
*/
team: Partial<Team>;
/**
* The oauthApp data
*/
oauthApp: OAuthApp;
/**
* Whether the oauth app is created by an App
*/
fromApp: boolean;
creatorName: string;
/**
* The function to call when Regenerate Secret link is clicked
*/
onRegenerateSecret: (oauthAppId: string) => Promise<{ error?: { message: string } }>;
/**
* The function to call when Delete link is clicked
*/
onDelete: (oauthApp: OAuthApp) => void;
/**
* Set to filter OAuthApp
*/
filter?: string | null;
}
export type InstalleOAuthAppState = {
clientSecret: string;
error?: string | null;
}
export default class InstalledOAuthApp extends React.PureComponent<InstalledOAuthAppProps, InstalleOAuthAppState> {
constructor(props: InstalledOAuthAppProps) {
super(props);
this.state = {
clientSecret: FAKE_SECRET,
};
}
handleShowClientSecret = (e?: React.MouseEvent): void => {
if (e && e.preventDefault) {
e.preventDefault();
}
this.setState({clientSecret: this.props.oauthApp.client_secret});
}
handleHideClientSecret = (e: React.MouseEvent): void => {
e.preventDefault();
this.setState({clientSecret: FAKE_SECRET});
}
handleRegenerate = (e: React.MouseEvent): void => {
e.preventDefault();
this.props.onRegenerateSecret(this.props.oauthApp.id).then(
({error}) => {
if (error) {
this.setState({error: error.message});
} else {
this.setState({error: null});
this.handleShowClientSecret();
}
},
);
}
handleDelete = (): void => {
this.props.onDelete(this.props.oauthApp);
}
render(): React.ReactNode {
const {oauthApp, creatorName} = this.props;
let error;
if (this.state.error) {
error = (
<FormError
error={this.state.error}
/>
);
}
if (!matchesFilter(oauthApp, this.props.filter)) {
return null;
}
let name;
if (oauthApp.name) {
name = oauthApp.name;
} else {
name = (
<FormattedMessage
id='installed_integrations.unnamed_oauth_app'
defaultMessage='Unnamed OAuth 2.0 Application'
/>
);
}
let description;
if (oauthApp.description) {
description = (
<div className='item-details__row'>
<span className='item-details__description'>
{oauthApp.description}
</span>
</div>
);
}
const urls = (
<div className='item-details__row'>
<span className='item-details__url word-break--all'>
<FormattedMessage
id='installed_integrations.callback_urls'
defaultMessage='Callback URLs: {urls}'
values={{
urls: oauthApp.callback_urls.join(', '),
}}
/>
</span>
</div>
);
let isTrusted;
if (oauthApp.is_trusted) {
isTrusted = Utils.localizeMessage('installed_oauth_apps.trusted.yes', 'Yes');
} else {
isTrusted = Utils.localizeMessage('installed_oauth_apps.trusted.no', 'No');
}
let showHide;
let clientSecret;
if (this.state.clientSecret === FAKE_SECRET) {
showHide = (
<button
id='showSecretButton'
className='style--none color--link'
onClick={this.handleShowClientSecret}
>
<FormattedMessage
id='installed_integrations.showSecret'
defaultMessage='Show Secret'
/>
</button>
);
clientSecret = (
<span className='item-details__token'>
<FormattedMessage
id='installed_integrations.client_secret'
defaultMessage='Client Secret: **{clientSecret}**'
values={{
clientSecret: this.state.clientSecret,
}}
/>
</span>
);
} else {
showHide = (
<button
id='hideSecretButton'
className='style--none color--link'
onClick={this.handleHideClientSecret}
>
<FormattedMessage
id='installed_integrations.hideSecret'
defaultMessage='Hide Secret'
/>
</button>
);
clientSecret = (
<span className='item-details__token'>
<FormattedMarkdownMessage
id='installed_integrations.client_secret'
defaultMessage='Client Secret: **{clientSecret}**'
values={{
clientSecret: this.state.clientSecret,
}}
/>
<CopyText
idMessage='integrations.copy_client_secret'
defaultMessage='Copy Client Secret'
value={this.state.clientSecret}
/>
</span>
);
}
const regen = (
<button
id='regenerateSecretButton'
className='style--none color--link'
onClick={this.handleRegenerate}
>
<FormattedMessage
id='installed_integrations.regenSecret'
defaultMessage='Regenerate Secret'
/>
</button>
);
let icon;
if (oauthApp.icon_url) {
icon = (
<div className='integration__icon integration-list__icon'>
<img
alt={'get app screenshot'}
src={oauthApp.icon_url}
/>
</div>
);
}
let actions;
if (!this.props.fromApp) {
actions = (
<div className='item-actions'>
{showHide}
{' - '}
{regen}
{' - '}
<Link to={`/${this.props.team.name}/integrations/oauth2-apps/edit?id=${oauthApp.id}`}>
<FormattedMessage
id='installed_integrations.edit'
defaultMessage='Edit'
/>
</Link>
{' - '}
<DeleteIntegrationLink
modalMessage={
<FormattedMessage
id='installed_oauth_apps.delete.confirm'
defaultMessage='This action permanently deletes the OAuth 2.0 application and breaks any integrations using it. Are you sure you want to delete it?'
/>
}
onDelete={this.handleDelete}
/>
</div>
);
}
let appInfo = (
<div className='item-details__row'>
<span className='item-details__creation'>
<FormattedMessage
id='installed_integrations.fromApp'
defaultMessage='Managed by Apps Framework'
/>
</span>
</div>
);
if (!this.props.fromApp) {
appInfo = (
<>
<div className='item-details__row'>
<span className='item-details__url word-break--all'>
<FormattedMarkdownMessage
id='installed_oauth_apps.is_trusted'
defaultMessage='Is Trusted: **{isTrusted}**'
values={{
isTrusted,
}}
/>
</span>
</div>
<div className='item-details__row'>
<span className='item-details__token'>
<FormattedMarkdownMessage
id='installed_integrations.client_id'
defaultMessage='Client ID: **{clientId}**'
values={{
clientId: oauthApp.id,
}}
/>
<CopyText
idMessage='integrations.copy_client_id'
defaultMessage='Copy Client Id'
value={oauthApp.id}
/>
</span>
</div>
<div className='item-details__row'>
{clientSecret}
</div>
{urls}
<div className='item-details__row'>
<span className='item-details__creation'>
<FormattedMessage
id='installed_integrations.creation'
defaultMessage='Created by {creator} on {createAt, date, full}'
values={{
creator: creatorName,
createAt: oauthApp.create_at,
}}
/>
</span>
</div>
</>
);
}
return (
<div className='backstage-list__item'>
{icon}
<div className='item-details'>
<div className='item-details__row d-flex flex-column flex-md-row justify-content-between'>
<strong className='item-details__name'>
{name}
</strong>
{actions}
</div>
{error}
{description}
{appInfo}
</div>
</div>
);
}
}

Просмотреть файл

@@ -0,0 +1,130 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/InstalledOAuthApps should match snapshot 1`] = `
<BackstageList
addButtonId="addOauthApp"
addLink="/test/integrations/oauth2-apps/add"
addText="Add OAuth 2.0 Application"
emptyText={
<Memo(MemoizedFormattedMessage)
defaultMessage="No OAuth 2.0 Applications found"
id="installed_oauth_apps.empty"
/>
}
emptyTextSearch={
<FormattedMarkdownMessage
defaultMessage="No OAuth 2.0 Applications match {searchTerm}"
id="installed_oauth_apps.emptySearch"
/>
}
header={
<Memo(MemoizedFormattedMessage)
defaultMessage="OAuth 2.0 Applications"
id="installed_oauth_apps.header"
/>
}
helpText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Create {oauthApplications} to securely integrate bots and third-party apps with Mattermost. Visit the {appDirectory} to find available self-hosted apps."
id="installed_oauth_apps.help"
values={
Object {
"appDirectory": <ExternalLink
href="https://mattermost.com/marketplace/"
location="installed_oauth_apps"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="App Directory"
id="installed_oauth_apps.help.appDirectory"
/>
</ExternalLink>,
"oauthApplications": <ExternalLink
href="https://developers.mattermost.com/integrate/admin-guide/admin-oauth2/"
location="installed_oauth_apps"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="OAuth 2.0 applications"
id="installed_oauth_apps.help.oauthApplications"
/>
</ExternalLink>,
}
}
/>
}
loading={true}
searchPlaceholder="Search OAuth 2.0 Applications"
>
<Component />
</BackstageList>
`;
exports[`components/integrations/InstalledOAuthApps should match snapshot 2`] = `
<div>
<Connect(InstalledOAuthApp)
creatorName=""
fromApp={false}
key="facxd9wpzpbpfp8pad78xj75pr"
oauthApp={
Object {
"callback_urls": Array [
"https://test.com/callback",
],
"client_secret": "88cxd9wpzpbpfp8pad78xj75pr",
"create_at": 1501365458934,
"creator_id": "88oybd1dwfdoxpkpw1h5kpbyco",
"description": "testing",
"homepage": "https://test.com",
"icon_url": "https://test.com/icon",
"id": "facxd9wpzpbpfp8pad78xj75pr",
"is_trusted": false,
"name": "firstApp",
"update_at": 1501365458934,
}
}
onDelete={[Function]}
onRegenerateSecret={[MockFunction]}
team={
Object {
"name": "test",
}
}
/>
</div>
`;
exports[`components/integrations/InstalledOAuthApps should match snapshot 3`] = `<div />`;
exports[`components/integrations/InstalledOAuthApps should match snapshot for Apps 1`] = `
<div>
<Connect(InstalledOAuthApp)
creatorName=""
fromApp={true}
key="fzcxd9wpzpbpfp8pad78xj75pr"
oauthApp={
Object {
"callback_urls": Array [
"https://test2.com/callback",
"https://test2.com/callback2",
],
"client_secret": "decxd9wpzpbpfp8pad78xj75pr",
"create_at": 1501365459984,
"creator_id": "88oybd2dwfdoxpkpw1h5kpbyco",
"description": "testing2",
"homepage": "https://test2.com",
"icon_url": "https://test2.com/icon",
"id": "fzcxd9wpzpbpfp8pad78xj75pr",
"is_trusted": true,
"name": "secondApp",
"update_at": 1501365479988,
}
}
onDelete={[Function]}
onRegenerateSecret={[MockFunction]}
team={
Object {
"name": "test",
}
}
/>
</div>
`;

Просмотреть файл

@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux';
import {regenOAuthAppSecret, deleteOAuthApp} from 'mattermost-redux/actions/integrations';
import {getAppsOAuthAppIDs, getOAuthApps} from 'mattermost-redux/selectors/entities/integrations';
import {haveISystemPermission} from 'mattermost-redux/selectors/entities/roles';
import {Permissions} from 'mattermost-redux/constants';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {GlobalState} from '@mattermost/types/store';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {GenericAction} from 'mattermost-redux/types/actions';
import {loadOAuthAppsAndProfiles} from 'actions/integration_actions';
import InstalledOAuthApps from './installed_oauth_apps';
function mapStateToProps(state: GlobalState) {
const config = getConfig(state);
const enableOAuthServiceProvider = config.EnableOAuthServiceProvider === 'true';
return {
canManageOauth: haveISystemPermission(state, {permission: Permissions.MANAGE_OAUTH}),
oauthApps: getOAuthApps(state),
appsOAuthAppIDs: getAppsOAuthAppIDs(state),
enableOAuthServiceProvider,
team: getCurrentTeam(state),
};
}
type Actions = {
loadOAuthAppsAndProfiles: (page?: number, perPage?: number) => Promise<void>;
regenOAuthAppSecret: (appId: string) => Promise<{ error?: Error }>;
deleteOAuthApp: (appId: string) => Promise<void>;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject, Actions>({
loadOAuthAppsAndProfiles,
regenOAuthAppSecret,
deleteOAuthApp,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(InstalledOAuthApps);

Просмотреть файл

@@ -0,0 +1,111 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import InstalledOAuthApps from 'components/integrations/installed_oauth_apps/installed_oauth_apps';
import BackstageList from 'components/backstage/components/backstage_list';
describe('components/integrations/InstalledOAuthApps', () => {
const oauthApps = {
facxd9wpzpbpfp8pad78xj75pr: {
id: 'facxd9wpzpbpfp8pad78xj75pr',
name: 'firstApp',
client_secret: '88cxd9wpzpbpfp8pad78xj75pr',
create_at: 1501365458934,
creator_id: '88oybd1dwfdoxpkpw1h5kpbyco',
description: 'testing',
homepage: 'https://test.com',
icon_url: 'https://test.com/icon',
is_trusted: false,
update_at: 1501365458934,
callback_urls: ['https://test.com/callback'],
},
fzcxd9wpzpbpfp8pad78xj75pr: {
id: 'fzcxd9wpzpbpfp8pad78xj75pr',
name: 'secondApp',
client_secret: 'decxd9wpzpbpfp8pad78xj75pr',
create_at: 1501365459984,
creator_id: '88oybd2dwfdoxpkpw1h5kpbyco',
description: 'testing2',
homepage: 'https://test2.com',
icon_url: 'https://test2.com/icon',
is_trusted: true,
update_at: 1501365479988,
callback_urls: ['https://test2.com/callback', 'https://test2.com/callback2'],
},
};
const baseProps = {
team: {
name: 'test',
},
oauthApps,
canManageOauth: true,
actions: {
loadOAuthAppsAndProfiles: jest.fn(),
regenOAuthAppSecret: jest.fn(),
deleteOAuthApp: jest.fn(),
},
enableOAuthServiceProvider: true,
appsOAuthAppIDs: [],
};
test('should match snapshot', () => {
const newGetOAuthApps = jest.fn().mockImplementation(
() => {
return new Promise((resolve) => {
process.nextTick(() => resolve({}));
});
},
);
const props = {...baseProps};
props.actions.loadOAuthAppsAndProfiles = newGetOAuthApps;
const wrapper = shallow<InstalledOAuthApps>(<InstalledOAuthApps {...props}/>);
expect(wrapper).toMatchSnapshot();
expect(shallow(<div>{wrapper.instance().oauthApps('first')}</div>)).toMatchSnapshot(); // successful filter
expect(shallow(<div>{wrapper.instance().oauthApps('ZZZ')}</div>)).toMatchSnapshot(); // unsuccessful filter
expect(shallow(<div>{wrapper.instance().oauthApps()}</div>).find('Connect(InstalledOAuthApp)').length).toBe(2); // no filter, should return all
expect(wrapper.find(BackstageList).props().addLink).toEqual('/test/integrations/oauth2-apps/add');
expect(wrapper.find(BackstageList).props().addText).toEqual('Add OAuth 2.0 Application');
wrapper.setProps({canManageOauth: false});
expect(wrapper.find(BackstageList).props().addLink).toBeFalsy();
expect(wrapper.find(BackstageList).props().addText).toBeFalsy();
});
test('should match snapshot for Apps', () => {
const newGetOAuthApps = jest.fn().mockImplementation(
() => {
return new Promise((resolve) => {
process.nextTick(() => resolve({}));
});
},
);
const props = {
...baseProps,
appsOAuthAppIDs: ['fzcxd9wpzpbpfp8pad78xj75pr'],
};
props.actions.loadOAuthAppsAndProfiles = newGetOAuthApps;
const wrapper = shallow<InstalledOAuthApps>(<InstalledOAuthApps {...props}/>);
expect(shallow(<div>{wrapper.instance().oauthApps('second')}</div>)).toMatchSnapshot(); // successful filter
});
test('should props.deleteOAuthApp on deleteOAuthApp', () => {
const newDeleteOAuthApp = jest.fn();
const props = {...baseProps};
props.actions.deleteOAuthApp = newDeleteOAuthApp;
const wrapper = shallow<InstalledOAuthApps>(
<InstalledOAuthApps {...props}/>,
);
wrapper.instance().deleteOAuthApp(oauthApps.facxd9wpzpbpfp8pad78xj75pr);
expect(newDeleteOAuthApp).toHaveBeenCalled();
expect(newDeleteOAuthApp).toHaveBeenCalledWith(oauthApps.facxd9wpzpbpfp8pad78xj75pr.id);
});
});

Просмотреть файл

@@ -0,0 +1,194 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {OAuthApp} from '@mattermost/types/integrations';
import {localizeMessage} from 'utils/utils';
import BackstageList from 'components/backstage/components/backstage_list';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import InstalledOAuthApp from '../installed_oauth_app';
import {matchesFilter} from '../installed_oauth_app/installed_oauth_app';
import ExternalLink from 'components/external_link';
type Props = {
/**
* The team data
*/
team: {name: string};
/**
* The oauthApps data
*/
oauthApps: {
[key: string]: OAuthApp;
};
/**
* List of IDs for apps managed by the App Framwork
*/
appsOAuthAppIDs: string[];
/**
* Set if user can manage oath
*/
canManageOauth: boolean;
/**
* Whether or not OAuth applications are enabled.
*/
enableOAuthServiceProvider: boolean;
actions: ({
/**
* The function to call to fetch OAuth apps
*/
loadOAuthAppsAndProfiles: (page?: number, perPage?: number) => Promise<void>;
/**
* The function to call when Regenerate Secret link is clicked
*/
regenOAuthAppSecret: (appId: string) => Promise<{ error?: Error }>;
/**
* The function to call when Delete link is clicked
*/
deleteOAuthApp: (appId: string) => Promise<void>;
});
};
type State = {
loading: boolean;
};
export default class InstalledOAuthApps extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loading: true,
};
}
componentDidMount(): void {
if (this.props.enableOAuthServiceProvider) {
this.props.actions.loadOAuthAppsAndProfiles().then(
() => this.setState({loading: false}),
);
}
}
deleteOAuthApp = (app: OAuthApp): void => {
if (app && app.id) {
this.props.actions.deleteOAuthApp(app.id);
}
}
oauthAppCompare(a: OAuthApp, b: OAuthApp): number {
let nameA = a.name.toString();
if (!nameA) {
nameA = localizeMessage('installed_integrations.unnamed_oauth_app', 'Unnamed OAuth 2.0 Application');
}
let nameB = b.name.toString();
if (!nameB) {
nameB = localizeMessage('installed_integrations.unnamed_oauth_app', 'Unnamed OAuth 2.0 Application');
}
return nameA.localeCompare(nameB);
}
oauthApps = (filter?: string) => Object.values(this.props.oauthApps).
filter((app) => matchesFilter(app, filter)).
sort(this.oauthAppCompare).
map((app) => {
return (
<InstalledOAuthApp
key={app.id}
oauthApp={app}
onRegenerateSecret={this.props.actions.regenOAuthAppSecret}
onDelete={this.deleteOAuthApp}
team={this.props.team}
creatorName=''
fromApp={this.props.appsOAuthAppIDs.includes(app.id)}
/>
);
});
render(): JSX.Element {
const integrationsEnabled = this.props.enableOAuthServiceProvider && this.props.canManageOauth;
let props;
if (integrationsEnabled) {
props = {
addLink: '/' + this.props.team.name + '/integrations/oauth2-apps/add',
addText: localizeMessage('installed_oauth_apps.add', 'Add OAuth 2.0 Application'),
addButtonId: 'addOauthApp',
};
}
return (
<BackstageList
header={
<FormattedMessage
id='installed_oauth_apps.header'
defaultMessage='OAuth 2.0 Applications'
/>
}
helpText={
<FormattedMessage
id='installed_oauth_apps.help'
defaultMessage='Create {oauthApplications} to securely integrate bots and third-party apps with Mattermost. Visit the {appDirectory} to find available self-hosted apps.'
values={{
oauthApplications: (
<ExternalLink
href='https://developers.mattermost.com/integrate/admin-guide/admin-oauth2/'
location='installed_oauth_apps'
>
<FormattedMessage
id='installed_oauth_apps.help.oauthApplications'
defaultMessage='OAuth 2.0 applications'
/>
</ExternalLink>
),
appDirectory: (
<ExternalLink
href='https://mattermost.com/marketplace/'
location='installed_oauth_apps'
>
<FormattedMessage
id='installed_oauth_apps.help.appDirectory'
defaultMessage='App Directory'
/>
</ExternalLink>
),
}}
/>
}
emptyText={
<FormattedMessage
id='installed_oauth_apps.empty'
defaultMessage='No OAuth 2.0 Applications found'
/>
}
emptyTextSearch={
<FormattedMarkdownMessage
id='installed_oauth_apps.emptySearch'
defaultMessage='No OAuth 2.0 Applications match {searchTerm}'
/>
}
searchPlaceholder={localizeMessage('installed_oauth_apps.search', 'Search OAuth 2.0 Applications')}
loading={this.state.loading}
{...props}
>
{(filter: string) => {
const children = this.oauthApps(filter);
return [children, children.length > 0];
}}
</BackstageList>
);
}
}

Просмотреть файл

@@ -0,0 +1,176 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {Link} from 'react-router-dom';
import DeleteIntegrationLink from 'components/integrations/delete_integration_link';
import InstalledOutgoingWebhook, {matchesFilter} from 'components/integrations/installed_outgoing_webhook';
describe('components/integrations/InstalledOutgoingWebhook', () => {
const team = {
id: 'testteamid',
name: 'test',
};
const channel = {
id: '1jiw9kphbjrntfyrm7xpdcya4o',
name: 'town-square',
display_name: 'Town Square',
};
const outgoingWebhook = {
callback_urls: ['http://adsfdasd.com'],
channel_id: 'mdpzfpfcxi85zkkqkzkch4b85h',
content_type: 'application/x-www-form-urlencoded',
create_at: 1508327769020,
creator_id: 'zaktnt8bpbgu8mb6ez9k64r7sa',
delete_at: 0,
description: 'build status',
display_name: 'build',
id: '7h88x419ubbyuxzs7dfwtgkffr',
team_id: 'eatxocwc3bg9ffo9xyybnj4omr',
token: 'xoxz1z7c3tgi9xhrfudn638q9r',
trigger_when: 0,
trigger_words: ['build'],
0: 'asdf',
update_at: 1508329149618,
};
const baseProps = {
outgoingWebhook,
onRegenToken: () => {}, //eslint-disable-line no-empty-function
onDelete: () => {}, //eslint-disable-line no-empty-function
filter: '',
creator: {username: 'username'},
canChange: true,
team,
channel,
};
test('should match snapshot', () => {
const wrapper = shallow(
<InstalledOutgoingWebhook {...baseProps}/>,
);
expect(wrapper).toMatchSnapshot();
});
test('should not have edit and delete actions if user does not have permissions to change', () => {
const newCanChange = false;
const props = {...baseProps, canChange: newCanChange};
const wrapper = shallow(
<InstalledOutgoingWebhook {...props}/>,
);
expect(wrapper.find('.item-actions').length).toBe(0);
});
test('should have edit and delete actions if user can change webhook', () => {
const wrapper = shallow(
<InstalledOutgoingWebhook {...baseProps}/>,
);
expect(wrapper.find('.item-actions').find(Link).exists()).toBe(true);
expect(wrapper.find('.item-actions').find(DeleteIntegrationLink).exists()).toBe(true);
});
test('Should have the same name and description on view as it has in outgoingWebhook', () => {
const newCanChange = false;
const props = {...baseProps, canChange: newCanChange};
const wrapper = shallow(
<InstalledOutgoingWebhook {...props}/>,
);
expect(wrapper.find('.item-details__description').text()).toBe('build status');
expect(wrapper.find('.item-details__name').text()).toBe('build');
});
test('Should not display description as it is null', () => {
const newOutgoingWebhook = {...outgoingWebhook, description: null};
const props = {...baseProps, outgoingWebhook: newOutgoingWebhook};
const wrapper = shallow(
<InstalledOutgoingWebhook {...props}/>,
);
expect(wrapper.find('.item-details__description').length).toBe(0);
});
test('Should not render any nodes as there are no filtered results', () => {
const newFilter = 'someLongText';
const props = {...baseProps, filter: newFilter};
const wrapper = shallow(
<InstalledOutgoingWebhook {...props}/>,
);
expect(wrapper.getElement()).toBe(null);
});
test('Should render a webhook item as filtered result is true', () => {
const newFilter = 'buil';
const props = {...baseProps, filter: newFilter};
const wrapper = shallow(
<InstalledOutgoingWebhook {...props}/>,
);
expect(wrapper.find('.item-details').exists()).toBe(true);
});
test('Should call onRegenToken function once', () => {
const newFilter = 'buil';
const newOnRegenToken = jest.fn();
const props = {...baseProps, filter: newFilter, onRegenToken: newOnRegenToken};
const wrapper = shallow(
<InstalledOutgoingWebhook {...props}/>,
);
wrapper.find('.item-actions button').first().simulate('click', {preventDefault() {
return jest.fn();
}});
expect(newOnRegenToken).toHaveBeenCalledTimes(1);
});
test('Should call onDelete function once', () => {
const newFilter = 'buil';
const newOnDelete = jest.fn();
const props = {...baseProps, filter: newFilter, onDelete: newOnDelete};
const wrapper = shallow(
<InstalledOutgoingWebhook {...props}/>,
);
wrapper.find(DeleteIntegrationLink).first().prop('onDelete')();
expect(newOnDelete).toHaveBeenCalledTimes(1);
});
test('Should match snapshot of makeDisplayName', () => {
const wrapper = shallow(
<InstalledOutgoingWebhook {...baseProps}/>,
);
// displays webhook's display name
expect(wrapper.instance().makeDisplayName({display_name: 'hook display name'}, {})).toMatchSnapshot();
// displays channel's display name
expect(wrapper.instance().makeDisplayName({}, {display_name: 'channel display name'})).toMatchSnapshot();
// displays a private hook
expect(wrapper.instance().makeDisplayName({})).toMatchSnapshot();
});
test('Should match result when matchesFilter is called', () => {
expect(matchesFilter({}, {}, 'word')).toEqual(false);
expect(matchesFilter({display_name: null}, {}, 'word')).toEqual(false);
expect(matchesFilter({description: null}, {}, 'word')).toEqual(false);
expect(matchesFilter({trigger_words: null}, {}, 'word')).toEqual(false);
expect(matchesFilter({}, {name: null}, 'channel')).toEqual(false);
expect(matchesFilter({}, {}, '')).toEqual(true);
expect(matchesFilter({display_name: 'Word'}, {}, 'word')).toEqual(true);
expect(matchesFilter({display_name: 'word'}, {}, 'word')).toEqual(true);
expect(matchesFilter({description: 'Trigger description'}, {}, 'description')).toEqual(true);
expect(matchesFilter({trigger_words: ['Trigger']}, {}, 'trigger')).toEqual(true);
expect(matchesFilter({trigger_words: ['word', 'Trigger']}, {}, 'trigger')).toEqual(true);
expect(matchesFilter({}, {name: 'channel_name'}, 'channel')).toEqual(true);
});
});

Просмотреть файл

@@ -0,0 +1,258 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {Link} from 'react-router-dom';
import CopyText from 'components/copy_text';
import {OutgoingWebhook} from '@mattermost/types/integrations';
import {Team} from '@mattermost/types/teams';
import {UserProfile} from '@mattermost/types/users';
import {Channel} from '@mattermost/types/channels';
import DeleteIntegrationLink from './delete_integration_link';
export function matchesFilter(outgoingWebhook: OutgoingWebhook, channel: Channel, filter: string) {
if (!filter) {
return true;
}
const {
display_name: displayName,
description,
trigger_words: triggerWords,
} = outgoingWebhook;
if (
(displayName && displayName.toLowerCase().indexOf(filter) !== -1) ||
(description && description.toLowerCase().indexOf(filter) !== -1)
) {
return true;
}
if (triggerWords) {
for (const triggerWord of triggerWords) {
if (triggerWord.toLowerCase().indexOf(filter) !== -1) {
return true;
}
}
}
if (channel && channel.name) {
if (channel.name.toLowerCase().indexOf(filter) !== -1) {
return true;
}
}
return false;
}
type Props = {
outgoingWebhook: OutgoingWebhook;
onRegenToken: (outgoingWebhook: OutgoingWebhook) => void;
onDelete: (outgoingWebhook: OutgoingWebhook) => void;
team: Team;
creator: UserProfile;
channel: Channel;
canChange: boolean;
filter?: string;
}
export default class InstalledOutgoingWebhook extends React.PureComponent<Props> {
handleRegenToken = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
this.props.onRegenToken(this.props.outgoingWebhook);
}
handleDelete = () => {
this.props.onDelete(this.props.outgoingWebhook);
}
makeDisplayName(outgoingWebhook: OutgoingWebhook, channel: Channel) {
if (outgoingWebhook.display_name) {
return outgoingWebhook.display_name;
} else if (channel) {
return channel.display_name;
}
return (
<FormattedMessage
id='installed_outgoing_webhooks.unknown_channel'
defaultMessage='A Private Webhook'
/>
);
}
render() {
const outgoingWebhook = this.props.outgoingWebhook;
const channel = this.props.channel;
const filter = this.props.filter ? this.props.filter.toLowerCase() : '';
const triggerWordsFull = 0;
const triggerWordsStartsWith = 1;
if (outgoingWebhook && !matchesFilter(outgoingWebhook, channel, filter)) {
return null;
}
const displayName = this.makeDisplayName(outgoingWebhook, channel);
let description = null;
if (outgoingWebhook.description) {
description = (
<div className='item-details__row'>
<span className='item-details__description'>
{outgoingWebhook.description}
</span>
</div>
);
}
let triggerWords = null;
if (outgoingWebhook.trigger_words && outgoingWebhook.trigger_words.length > 0) {
triggerWords = (
<div className='item-details__row'>
<span className='item-details__trigger-words'>
<FormattedMessage
id='installed_integrations.triggerWords'
defaultMessage='Trigger Words: {triggerWords}'
values={{
triggerWords: outgoingWebhook.trigger_words.join(', '),
}}
/>
</span>
</div>
);
}
const urls = (
<div className='item-details__row'>
<span className='item-details__url word-break--all'>
<FormattedMessage
id='installed_integrations.callback_urls'
defaultMessage='Callback URLs: {urls}'
values={{
urls: outgoingWebhook.callback_urls.join(', '),
}}
/>
</span>
</div>
);
let triggerWhen;
if (outgoingWebhook.trigger_when === triggerWordsFull) {
triggerWhen = (
<FormattedMessage
id='add_outgoing_webhook.triggerWordsTriggerWhenFullWord'
defaultMessage='First word matches a trigger word exactly'
/>
);
} else if (outgoingWebhook.trigger_when === triggerWordsStartsWith) {
triggerWhen = (
<FormattedMessage
id='add_outgoing_webhook.triggerWordsTriggerWhenStartsWith'
defaultMessage='First word starts with a trigger word'
/>
);
}
let actions = null;
if (this.props.canChange) {
actions = (
<div className='item-actions'>
<button
className='style--none color--link'
onClick={this.handleRegenToken}
>
<FormattedMessage
id='installed_integrations.regenToken'
defaultMessage='Regen Token'
/>
</button>
{' - '}
<Link to={`/${this.props.team.name}/integrations/outgoing_webhooks/edit?id=${outgoingWebhook.id}`}>
<FormattedMessage
id='installed_integrations.edit'
defaultMessage='Edit'
/>
</Link>
{' - '}
<DeleteIntegrationLink
modalMessage={
<FormattedMessage
id='installed_outgoing_webhooks.delete.confirm'
defaultMessage='This action permanently deletes the outgoing webhook and breaks any integrations using it. Are you sure you want to delete it?'
/>
}
onDelete={this.handleDelete}
/>
</div>
);
}
return (
<div className='backstage-list__item'>
<div className='item-details'>
<div className='item-details__row d-flex flex-column flex-md-row justify-content-between'>
<strong className='item-details__name'>
{displayName}
</strong>
{actions}
</div>
{description}
<div className='item-details__row'>
<span className='item-details__content_type'>
<FormattedMessage
id='installed_integrations.content_type'
defaultMessage='Content-Type: {contentType}'
values={{
contentType: outgoingWebhook.content_type || 'application/x-www-form-urlencoded',
}}
/>
</span>
</div>
{triggerWords}
<div className='item-details__row'>
<span className='item-details__trigger-when'>
<FormattedMessage
id='installed_integrations.triggerWhen'
defaultMessage='Trigger When: {triggerWhen}'
values={{
triggerWhen,
}}
/>
</span>
</div>
<div className='item-details__row'>
<span className='item-details__token'>
<FormattedMessage
id='installed_integrations.token'
defaultMessage='Token: {token}'
values={{
token: outgoingWebhook.token,
}}
/>
<CopyText
value={outgoingWebhook.token}
/>
</span>
</div>
<div className='item-details__row'>
<span className='item-details__creation'>
<FormattedMessage
id='installed_integrations.creation'
defaultMessage='Created by {creator} on {createAt, date, full}'
values={{
creator: this.props.creator.username,
createAt: outgoingWebhook.create_at,
}}
/>
</span>
</div>
{urls}
</div>
</div>
);
}
}

Просмотреть файл

@@ -0,0 +1,283 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/integrations/InstalledOutgoingWebhooks should match snapshot 1`] = `
<div>
<InstalledOutgoingWebhook
canChange={true}
channel={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "town-square",
"group_constrained": false,
"header": "header",
"id": "mdpzfpfcxi85zkkqkzkch4b85h",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "town-square",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "O",
"update_at": 0,
}
}
creator={
Object {
"auth_service": "",
"bot_description": "",
"create_at": 0,
"delete_at": 0,
"email": "",
"first_name": "sudheer",
"id": "zaktnt8bpbgu8mb6ez9k64r7sa",
"is_bot": false,
"last_activity_at": 0,
"last_name": "",
"last_password_update": 0,
"last_picture_update": 0,
"locale": "",
"mfa_active": false,
"nickname": "",
"notify_props": Object {
"channel": "false",
"comments": "never",
"desktop": "default",
"desktop_sound": "false",
"email": "false",
"first_name": "false",
"mark_unread": "mention",
"mention_keys": "",
"push": "none",
"push_status": "offline",
},
"password": "",
"position": "",
"props": Object {},
"roles": "system_admin system_user",
"terms_of_service_create_at": 0,
"terms_of_service_id": "",
"update_at": 0,
"username": "sudheerdev",
}
}
key="7h88x419ubbyuxzs7dfwtgkfgr"
onDelete={[Function]}
onRegenToken={[Function]}
outgoingWebhook={
Object {
"0": "asdf",
"callback_urls": Array [
"http://adsfdasd.com",
],
"channel_id": "mdpzfpfcxi85zkkqkzkch4b85h",
"content_type": "application/x-www-form-urlencoded",
"create_at": 1508327769020,
"creator_id": "zaktnt8bpbgu8mb6ez9k64r7sa",
"delete_at": 0,
"description": "build status",
"display_name": "",
"id": "7h88x419ubbyuxzs7dfwtgkfgr",
"team_id": "eatxocwc3bg9ffo9xyybnj4omr",
"token": "xoxz1z7c3tgi9xhrfudn638q9r",
"trigger_when": 0,
"trigger_words": Array [
"build",
],
"update_at": 1508329149618,
}
}
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "testteamid",
"invite_id": "",
"name": "test",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
/>
<InstalledOutgoingWebhook
canChange={true}
channel={
Object {
"create_at": 0,
"creator_id": "id",
"delete_at": 0,
"display_name": "town-square",
"group_constrained": false,
"header": "header",
"id": "mdpzfpfcxi85zkkqkzkch4b85h",
"last_post_at": 0,
"last_root_post_at": 0,
"name": "town-square",
"purpose": "purpose",
"scheme_id": "id",
"team_id": "team_id",
"type": "O",
"update_at": 0,
}
}
creator={
Object {
"auth_service": "",
"bot_description": "",
"create_at": 0,
"delete_at": 0,
"email": "",
"first_name": "sudheer",
"id": "zaktnt8bpbgu8mb6ez9k64r7sa",
"is_bot": false,
"last_activity_at": 0,
"last_name": "",
"last_password_update": 0,
"last_picture_update": 0,
"locale": "",
"mfa_active": false,
"nickname": "",
"notify_props": Object {
"channel": "false",
"comments": "never",
"desktop": "default",
"desktop_sound": "false",
"email": "false",
"first_name": "false",
"mark_unread": "mention",
"mention_keys": "",
"push": "none",
"push_status": "offline",
},
"password": "",
"position": "",
"props": Object {},
"roles": "system_admin system_user",
"terms_of_service_create_at": 0,
"terms_of_service_id": "",
"update_at": 0,
"username": "sudheerdev",
}
}
key="7h88x419ubbyuxzs7dfwtgkffr"
onDelete={[Function]}
onRegenToken={[Function]}
outgoingWebhook={
Object {
"0": "asdf",
"callback_urls": Array [
"http://adsfdasd.com",
],
"channel_id": "mdpzfpfcxi85zkkqkzkch4b85h",
"content_type": "application/x-www-form-urlencoded",
"create_at": 1508327769020,
"creator_id": "zaktnt8bpbgu8mb6ez9k64r7sa",
"delete_at": 0,
"description": "test",
"display_name": "",
"id": "7h88x419ubbyuxzs7dfwtgkffr",
"team_id": "eatxocwc3bg9ffo9xyybnj4omr",
"token": "xoxz1z7c3tgi9xhrfudn638q9r",
"trigger_when": 0,
"trigger_words": Array [
"test",
],
"update_at": 1508329149618,
}
}
team={
Object {
"allow_open_invite": false,
"allowed_domains": "",
"company_name": "",
"create_at": 0,
"delete_at": 0,
"description": "",
"display_name": "name",
"email": "",
"group_constrained": false,
"id": "testteamid",
"invite_id": "",
"name": "test",
"scheme_id": "id",
"type": "O",
"update_at": 0,
}
}
/>
</div>
`;
exports[`components/integrations/InstalledOutgoingWebhooks should match snapshot 2`] = `<div />`;
exports[`components/integrations/InstalledOutgoingWebhooks should match snapshot 3`] = `
<BackstageList
addButtonId="addOutgoingWebhook"
addLink="/test/integrations/outgoing_webhooks/add"
addText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Add Outgoing Webhook"
id="installed_outgoing_webhooks.add"
/>
}
emptyText={
<Memo(MemoizedFormattedMessage)
defaultMessage="No outgoing webhooks found"
id="installed_outgoing_webhooks.empty"
/>
}
emptyTextSearch={
<FormattedMarkdownMessage
defaultMessage="No outgoing webhooks match {searchTerm}"
id="installed_outgoing_webhooks.emptySearch"
/>
}
header={
<Memo(MemoizedFormattedMessage)
defaultMessage="Installed Outgoing Webhooks"
id="installed_outgoing_webhooks.header"
/>
}
helpText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Use outgoing webhooks to connect external tools to Mattermost. {buildYourOwn} or visit the {appDirectory} to find self-hosted, third-party apps and integrations."
id="installed_outgoing_webhooks.help"
values={
Object {
"appDirectory": <ExternalLink
href="https://mattermost.com/marketplace"
location="installed_outgoing_webhooks"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="App Directory"
id="installed_outgoing_webhooks.help.appDirectory"
/>
</ExternalLink>,
"buildYourOwn": <ExternalLink
href="https://developers.mattermost.com/integrate/admin-guide/admin-webhooks-outgoing/"
location="installed_outgoing_webhooks"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Build your own"
id="installed_outgoing_webhooks.help.buildYourOwn"
/>
</ExternalLink>,
}
}
/>
}
loading={true}
searchPlaceholder="Search Outgoing Webhooks"
>
<Component />
</BackstageList>
`;

Просмотреть файл

@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import * as Actions from 'mattermost-redux/actions/integrations';
import {getOutgoingHooks} from 'mattermost-redux/selectors/entities/integrations';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getAllChannels} from 'mattermost-redux/selectors/entities/channels';
import {getUsers} from 'mattermost-redux/selectors/entities/users';
import {haveITeamPermission} from 'mattermost-redux/selectors/entities/roles';
import {Permissions} from 'mattermost-redux/constants';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {loadOutgoingHooksAndProfilesForTeam} from 'actions/integration_actions';
import {GlobalState} from 'types/store';
import InstalledOutgoingWebhook, {Props} from './installed_outgoing_webhooks';
function mapStateToProps(state: GlobalState) {
const config = getConfig(state);
const teamId = getCurrentTeamId(state);
const canManageOthersWebhooks = haveITeamPermission(state, teamId, Permissions.MANAGE_OTHERS_OUTGOING_WEBHOOKS);
const outgoingHooks = getOutgoingHooks(state);
const outgoingWebhooks = Object.keys(outgoingHooks).
map((key) => outgoingHooks[key]).
filter((outgoingWebhook) => outgoingWebhook.team_id === teamId);
const enableOutgoingWebhooks = config.EnableOutgoingWebhooks === 'true';
return {
outgoingWebhooks,
channels: getAllChannels(state),
users: getUsers(state),
teamId,
canManageOthersWebhooks,
enableOutgoingWebhooks,
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<any>, Props['actions']>({
loadOutgoingHooksAndProfilesForTeam,
removeOutgoingHook: Actions.removeOutgoingHook,
regenOutgoingHookToken: Actions.regenOutgoingHookToken,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(InstalledOutgoingWebhook);

Просмотреть файл

@@ -0,0 +1,115 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ComponentProps} from 'react';
import {shallow} from 'enzyme';
import InstalledOutgoingWebhooks from 'components/integrations/installed_outgoing_webhooks/installed_outgoing_webhooks';
import {TestHelper} from 'utils/test_helper';
import {OutgoingWebhook} from '@mattermost/types/integrations';
describe('components/integrations/InstalledOutgoingWebhooks', () => {
const teamId = 'testteamid';
const team = TestHelper.getTeamMock({
id: teamId,
name: 'test',
});
const user = TestHelper.getUserMock({
first_name: 'sudheer',
id: 'zaktnt8bpbgu8mb6ez9k64r7sa',
roles: 'system_admin system_user',
username: 'sudheerdev',
});
const channel = TestHelper.getChannelMock({
id: 'mdpzfpfcxi85zkkqkzkch4b85h',
name: 'town-square',
display_name: 'town-square',
});
const outgoingWebhooks = [
{
callback_urls: ['http://adsfdasd.com'],
channel_id: 'mdpzfpfcxi85zkkqkzkch4b85h',
content_type: 'application/x-www-form-urlencoded',
create_at: 1508327769020,
creator_id: 'zaktnt8bpbgu8mb6ez9k64r7sa',
delete_at: 0,
description: 'build status',
display_name: '',
id: '7h88x419ubbyuxzs7dfwtgkfgr',
team_id: 'eatxocwc3bg9ffo9xyybnj4omr',
token: 'xoxz1z7c3tgi9xhrfudn638q9r',
trigger_when: 0,
trigger_words: ['build'],
0: 'asdf',
update_at: 1508329149618,
} as unknown as OutgoingWebhook,
{
callback_urls: ['http://adsfdasd.com'],
channel_id: 'mdpzfpfcxi85zkkqkzkch4b85h',
content_type: 'application/x-www-form-urlencoded',
create_at: 1508327769020,
creator_id: 'zaktnt8bpbgu8mb6ez9k64r7sa',
delete_at: 0,
description: 'test',
display_name: '',
id: '7h88x419ubbyuxzs7dfwtgkffr',
team_id: 'eatxocwc3bg9ffo9xyybnj4omr',
token: 'xoxz1z7c3tgi9xhrfudn638q9r',
trigger_when: 0,
trigger_words: ['test'],
0: 'asdf',
update_at: 1508329149618,
} as unknown as OutgoingWebhook,
];
const defaultProps: ComponentProps<typeof InstalledOutgoingWebhooks> = {
outgoingWebhooks,
teamId,
team,
user,
users: {zaktnt8bpbgu8mb6ez9k64r7sa: user},
channels: {mdpzfpfcxi85zkkqkzkch4b85h: channel},
actions: {
removeOutgoingHook: jest.fn(),
loadOutgoingHooksAndProfilesForTeam: jest.fn().mockReturnValue(Promise.resolve()),
regenOutgoingHookToken: jest.fn(),
},
enableOutgoingWebhooks: true,
canManageOthersWebhooks: true,
};
test('should match snapshot', () => {
const wrapper = shallow<InstalledOutgoingWebhooks>(
<InstalledOutgoingWebhooks
{...defaultProps}
/>,
);
expect(shallow(<div>{wrapper.instance().outgoingWebhooks('town')}</div>)).toMatchSnapshot();
expect(shallow(<div>{wrapper.instance().outgoingWebhooks('ZZZ')}</div>)).toMatchSnapshot();
expect(wrapper).toMatchSnapshot();
});
test('should call regenOutgoingHookToken function', () => {
const wrapper = shallow<InstalledOutgoingWebhooks>(
<InstalledOutgoingWebhooks
{...defaultProps}
/>,
);
wrapper.instance().regenOutgoingWebhookToken(outgoingWebhooks[0]);
expect(defaultProps.actions.regenOutgoingHookToken).toHaveBeenCalledTimes(1);
expect(defaultProps.actions.regenOutgoingHookToken).toHaveBeenCalledWith(outgoingWebhooks[0].id);
});
test('should call removeOutgoingHook function', () => {
const wrapper = shallow<InstalledOutgoingWebhooks>(
<InstalledOutgoingWebhooks
{...defaultProps}
/>,
);
wrapper.instance().removeOutgoingHook(outgoingWebhooks[1]);
expect(defaultProps.actions.removeOutgoingHook).toHaveBeenCalledTimes(1);
expect(defaultProps.actions.removeOutgoingHook).toHaveBeenCalledWith(outgoingWebhooks[1].id);
expect(defaultProps.actions.removeOutgoingHook).toHaveBeenCalled();
});
});

Просмотреть файл

@@ -0,0 +1,234 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {localizeMessage} from 'utils/utils';
import Constants from 'utils/constants';
import BackstageList from 'components/backstage/components/backstage_list';
import InstalledOutgoingWebhook, {matchesFilter} from 'components/integrations/installed_outgoing_webhook';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import {UserProfile} from '@mattermost/types/users';
import {Team} from '@mattermost/types/teams';
import {OutgoingWebhook} from '@mattermost/types/integrations';
import {IDMappedObjects} from '@mattermost/types/utilities';
import {Channel} from '@mattermost/types/channels';
import ExternalLink from 'components/external_link';
export type Props = {
/**
* Data used in passing down as props for webhook modifications
*/
team: Team;
/**
* Data used for checking if webhook is created by current user
*/
user: UserProfile;
/**
* Data used for checking modification privileges
*/
canManageOthersWebhooks: boolean;
/**
* Data used in passing down as props for showing webhook details
*/
outgoingWebhooks: OutgoingWebhook[];
/**
* Data used in sorting for displaying list and as props channel details
*/
channels: IDMappedObjects<Channel>;
/**
* Data used in passing down as props for webhook -created by label
*/
users: IDMappedObjects<UserProfile>;
/**
* Data used in passing as argument for loading webhooks
*/
teamId: string;
actions: {
/**
* The function to call for removing outgoingWebhook
*/
removeOutgoingHook: (hookId: string) => Promise<void>;
/**
* The function to call for outgoingWebhook List and for the status of api
*/
loadOutgoingHooksAndProfilesForTeam: (teamId: string, page: number, perPage: number) => Promise<void>;
/**
* The function to call for regeneration of webhook token
*/
regenOutgoingHookToken: (hookId: string) => Promise<void>;
};
/**
* Whether or not outgoing webhooks are enabled.
*/
enableOutgoingWebhooks: boolean;
}
type State = {
loading: boolean;
};
export default class InstalledOutgoingWebhooks extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loading: true,
};
}
componentDidMount() {
if (this.props.enableOutgoingWebhooks) {
this.props.actions.loadOutgoingHooksAndProfilesForTeam(
this.props.teamId,
Constants.Integrations.START_PAGE_NUM,
parseInt(Constants.Integrations.PAGE_SIZE, 10),
).then(
() => this.setState({loading: false}),
);
}
}
regenOutgoingWebhookToken = (outgoingWebhook: OutgoingWebhook) => {
this.props.actions.regenOutgoingHookToken(outgoingWebhook.id);
}
removeOutgoingHook = (outgoingWebhook: OutgoingWebhook) => {
this.props.actions.removeOutgoingHook(outgoingWebhook.id);
}
outgoingWebhookCompare = (a: OutgoingWebhook, b: OutgoingWebhook) => {
let displayNameA = a.display_name;
if (!displayNameA) {
const channelA = this.props.channels[a.channel_id];
if (channelA) {
displayNameA = channelA.display_name;
} else {
displayNameA = localizeMessage('installed_outgoing_webhooks.unknown_channel', 'A Private Webhook');
}
}
let displayNameB = b.display_name;
if (!displayNameB) {
const channelB = this.props.channels[b.channel_id];
if (channelB) {
displayNameB = channelB.display_name;
} else {
displayNameB = localizeMessage('installed_outgoing_webhooks.unknown_channel', 'A Private Webhook');
}
}
return displayNameA.localeCompare(displayNameB);
}
outgoingWebhooks = (filter: string) => this.props.outgoingWebhooks.
sort(this.outgoingWebhookCompare).
filter((outgoingWebhook) => matchesFilter(outgoingWebhook, this.props.channels[outgoingWebhook.channel_id], filter)).
map((outgoingWebhook) => {
const canChange = this.props.canManageOthersWebhooks || this.props.user.id === outgoingWebhook.creator_id;
const channel = this.props.channels[outgoingWebhook.channel_id];
return (
<InstalledOutgoingWebhook
key={outgoingWebhook.id}
outgoingWebhook={outgoingWebhook}
onRegenToken={this.regenOutgoingWebhookToken}
onDelete={this.removeOutgoingHook}
creator={this.props.users[outgoingWebhook.creator_id] || {}}
canChange={canChange}
team={this.props.team}
channel={channel}
/>
);
});
render() {
return (
<BackstageList
header={
<FormattedMessage
id='installed_outgoing_webhooks.header'
defaultMessage='Installed Outgoing Webhooks'
/>
}
addText={
<FormattedMessage
id='installed_outgoing_webhooks.add'
defaultMessage='Add Outgoing Webhook'
/>
}
addLink={
'/' +
this.props.team.name +
'/integrations/outgoing_webhooks/add'
}
addButtonId='addOutgoingWebhook'
emptyText={
<FormattedMessage
id='installed_outgoing_webhooks.empty'
defaultMessage='No outgoing webhooks found'
/>
}
emptyTextSearch={
<FormattedMarkdownMessage
id='installed_outgoing_webhooks.emptySearch'
defaultMessage='No outgoing webhooks match {searchTerm}'
/>
}
helpText={
<FormattedMessage
id='installed_outgoing_webhooks.help'
defaultMessage='Use outgoing webhooks to connect external tools to Mattermost. {buildYourOwn} or visit the {appDirectory} to find self-hosted, third-party apps and integrations.'
values={{
buildYourOwn: (
<ExternalLink
href='https://developers.mattermost.com/integrate/admin-guide/admin-webhooks-outgoing/'
location='installed_outgoing_webhooks'
>
<FormattedMessage
id='installed_outgoing_webhooks.help.buildYourOwn'
defaultMessage='Build your own'
/>
</ExternalLink>
),
appDirectory: (
<ExternalLink
href='https://mattermost.com/marketplace'
location='installed_outgoing_webhooks'
>
<FormattedMessage
id='installed_outgoing_webhooks.help.appDirectory'
defaultMessage='App Directory'
/>
</ExternalLink>
),
}}
/>
}
searchPlaceholder={localizeMessage(
'installed_outgoing_webhooks.search',
'Search Outgoing Webhooks',
)}
loading={this.state.loading}
>
{(filter: string) => {
const children = this.outgoingWebhooks(filter);
return [children, children.length > 0];
}}
</BackstageList>
);
}
}

Просмотреть файл

@@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Link} from 'react-router-dom';
type Props = {
image: string;
title: JSX.Element;
description: JSX.Element;
link: string;
}
export default class IntegrationOption extends React.PureComponent <Props> {
render() {
const {image, title, description, link} = this.props;
return (
<Link
to={link}
className='integration-option'
>
<img
alt={'integration image'}
className='integration-option__image'
src={image}
/>
<div className='integration-option__title'>
{title}
</div>
<div className='integration-option__description'>
{description}
</div>
</Link>
);
}
}

Просмотреть файл

@@ -0,0 +1,218 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {Team} from '@mattermost/types/teams.js';
import {Permissions} from 'mattermost-redux/constants';
import BotAccountsIcon from 'images/bot_default_icon.png';
import * as Utils from 'utils/utils';
import IncomingWebhookIcon from 'images/incoming_webhook.jpg';
import OAuthIcon from 'images/oauth_icon.png';
import OutgoingWebhookIcon from 'images/outgoing_webhook.jpg';
import SlashCommandIcon from 'images/slash_command_icon.jpg';
import SystemPermissionGate from 'components/permissions_gates/system_permission_gate';
import TeamPermissionGate from 'components/permissions_gates/team_permission_gate';
import ExternalLink from 'components/external_link';
import IntegrationOption from './integration_option';
type Props = {
siteName: string | undefined;
enableIncomingWebhooks: boolean;
enableOutgoingWebhooks: boolean;
enableCommands: boolean;
enableOAuthServiceProvider: boolean;
team: Team;
}
export default class Integrations extends React.PureComponent <Props> {
componentDidMount() {
this.updateTitle();
}
updateTitle = () => {
const currentSiteName = this.props.siteName || '';
document.title = Utils.localizeMessage('admin.sidebar.integrations', 'Integrations') + ' - ' + this.props.team.display_name + ' ' + currentSiteName;
}
render() {
const options = [];
if (this.props.enableIncomingWebhooks) {
options.push(
<TeamPermissionGate
teamId={this.props.team.id}
permissions={[Permissions.MANAGE_INCOMING_WEBHOOKS]}
key='incomingWebhookPermission'
>
<IntegrationOption
key='incomingWebhook'
image={IncomingWebhookIcon}
title={
<FormattedMessage
id='integrations.incomingWebhook.title'
defaultMessage='Incoming Webhooks'
/>
}
description={
<FormattedMessage
id='integrations.incomingWebhook.description'
defaultMessage='Incoming webhooks allow external integrations to send messages'
/>
}
link={'/' + this.props.team.name + '/integrations/incoming_webhooks'}
/>
</TeamPermissionGate>,
);
}
if (this.props.enableOutgoingWebhooks) {
options.push(
<TeamPermissionGate
teamId={this.props.team.id}
permissions={[Permissions.MANAGE_OUTGOING_WEBHOOKS]}
key='outgoingWebhookPermission'
>
<IntegrationOption
key='outgoingWebhook'
image={OutgoingWebhookIcon}
title={
<FormattedMessage
id='integrations.outgoingWebhook.title'
defaultMessage='Outgoing Webhooks'
/>
}
description={
<FormattedMessage
id='integrations.outgoingWebhook.description'
defaultMessage='Outgoing webhooks allow external integrations to receive and respond to messages'
/>
}
link={'/' + this.props.team.name + '/integrations/outgoing_webhooks'}
/>
</TeamPermissionGate>,
);
}
if (this.props.enableCommands) {
options.push(
<TeamPermissionGate
teamId={this.props.team.id}
permissions={[Permissions.MANAGE_SLASH_COMMANDS]}
key='commandPermission'
>
<IntegrationOption
key='command'
image={SlashCommandIcon}
title={
<FormattedMessage
id='integrations.command.title'
defaultMessage='Slash Commands'
/>
}
description={
<FormattedMessage
id='integrations.command.description'
defaultMessage='Slash commands send events to an external integration'
/>
}
link={'/' + this.props.team.name + '/integrations/commands'}
/>
</TeamPermissionGate>,
);
}
if (this.props.enableOAuthServiceProvider) {
options.push(
<SystemPermissionGate
permissions={[Permissions.MANAGE_OAUTH]}
key='oauth2AppsPermission'
>
<IntegrationOption
key='oauth2Apps'
image={OAuthIcon}
title={
<FormattedMessage
id='integrations.oauthApps.title'
defaultMessage='OAuth 2.0 Applications'
/>
}
description={
<FormattedMessage
id='integrations.oauthApps.description'
defaultMessage='Auth 2.0 allows external applications to make authorized requests to the Mattermost API'
/>
}
link={'/' + this.props.team.name + '/integrations/oauth2-apps'}
/>
</SystemPermissionGate>,
);
}
options.push(
<SystemPermissionGate
permissions={['manage_bots']}
key='botsPermissions'
>
<IntegrationOption
image={BotAccountsIcon}
title={
<FormattedMessage
id='bots.manage.header'
defaultMessage='Bot Accounts'
/>
}
description={
<FormattedMessage
id='bots.manage.description'
defaultMessage='Use bot accounts to integrate with Mattermost through plugins or the API'
/>
}
link={'/' + this.props.team.name + '/integrations/bots'}
/>
</SystemPermissionGate>,
);
return (
<div className='backstage-content row'>
<div className='backstage-header'>
<h1>
<FormattedMessage
id='integrations.header'
defaultMessage='Integrations'
/>
</h1>
</div>
<div className='backstage-list__help'>
<FormattedMessage
id='integrations.help'
defaultMessage='Visit the {appDirectory} to find self-hosted, third-party apps and integrations for Mattermost.'
values={{
appDirectory: (
<ExternalLink
href='https://mattermost.com/marketplace'
location='integrations'
>
<FormattedMessage
id='integrations.help.appDirectory'
defaultMessage='App Directory'
/>
</ExternalLink>
),
}}
/>
</div>
<div className='integrations-list d-flex flex-wrap'>
{options}
</div>
</div>
);
}
}