Mono repo -> Master (#22553)
Combines the following repositories into one: https://github.com/mattermost/mattermost-server https://github.com/mattermost/mattermost-webapp https://github.com/mattermost/focalboard https://github.com/mattermost/mattermost-plugin-playbooks
Этот коммит содержится в:
@@ -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>
|
||||
`;
|
||||
631
webapp/channels/src/components/integrations/abstract_command.jsx
Обычный файл
631
webapp/channels/src/components/integrations/abstract_command.jsx
Обычный файл
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
474
webapp/channels/src/components/integrations/abstract_oauth_app.jsx
Обычный файл
474
webapp/channels/src/components/integrations/abstract_oauth_app.jsx
Обычный файл
@@ -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;
|
||||
20
webapp/channels/src/components/integrations/add_command/index.ts
Обычный файл
20
webapp/channels/src/components/integrations/add_command/index.ts
Обычный файл
@@ -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);
|
||||
});
|
||||
});
|
||||
744
webapp/channels/src/components/integrations/bots/add_bot/add_bot.tsx
Обычный файл
744
webapp/channels/src/components/integrations/bots/add_bot/add_bot.tsx
Обычный файл
@@ -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);
|
||||
277
webapp/channels/src/components/integrations/bots/bot.test.tsx
Обычный файл
277
webapp/channels/src/components/integrations/bots/bot.test.tsx
Обычный файл
@@ -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);
|
||||
});
|
||||
});
|
||||
533
webapp/channels/src/components/integrations/bots/bot.tsx
Обычный файл
533
webapp/channels/src/components/integrations/bots/bot.tsx
Обычный файл
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
217
webapp/channels/src/components/integrations/bots/bots.test.tsx
Обычный файл
217
webapp/channels/src/components/integrations/bots/bots.test.tsx
Обычный файл
@@ -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);
|
||||
});
|
||||
});
|
||||
282
webapp/channels/src/components/integrations/bots/bots.tsx
Обычный файл
282
webapp/channels/src/components/integrations/bots/bots.tsx
Обычный файл
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
83
webapp/channels/src/components/integrations/bots/index.ts
Обычный файл
83
webapp/channels/src/components/integrations/bots/index.ts
Обычный файл
@@ -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);
|
||||
29
webapp/channels/src/components/integrations/index.ts
Обычный файл
29
webapp/channels/src/components/integrations/index.ts
Обычный файл
@@ -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();
|
||||
});
|
||||
});
|
||||
193
webapp/channels/src/components/integrations/installed_command.tsx
Обычный файл
193
webapp/channels/src/components/integrations/installed_command.tsx
Обычный файл
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
218
webapp/channels/src/components/integrations/integrations.tsx
Обычный файл
218
webapp/channels/src/components/integrations/integrations.tsx
Обычный файл
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user