From 56bfd5ab16987e242805adac8e2b42ddcd673608 Mon Sep 17 00:00:00 2001 From: Kshitij Katiyar Date: Fri, 17 Jun 2022 14:54:44 +0530 Subject: [PATCH] Added the feature to create pages from mm for both cloud and server --- server/client.go | 4 + server/client_cloud.go | 45 +++- server/client_server.go | 70 ++++++ server/create_page.go | 69 ++++++ server/get_spaces.go | 40 ++++ server/http.go | 2 + server/serializer/create_page.go | 16 ++ server/user.go | 9 + webapp/src/actions/index.js | 6 +- webapp/src/actions/subscription_modal.js | 58 +++++ webapp/src/client/client.js | 15 ++ webapp/src/components/confluence_field.jsx | 14 +- .../confluence_instance_selector.jsx | 52 +++++ .../confluence_space_selector.jsx | 54 +++++ .../create_confluence_page_modal.jsx | 202 ++++++++++++++++++ .../src/components/react_select_setting.jsx | 157 ++++++++++++++ webapp/src/components/setting.jsx | 60 ++++++ webapp/src/constants/action_types.js | 3 + webapp/src/hooks/index.js | 8 +- webapp/src/index.js | 3 + webapp/src/reducers/index.js | 4 +- webapp/src/reducers/subscription_modal.js | 24 +++ webapp/src/selectors/index.js | 12 ++ webapp/src/utils/styles.js | 119 +++++++++++ 24 files changed, 1040 insertions(+), 6 deletions(-) create mode 100644 server/create_page.go create mode 100644 server/get_spaces.go create mode 100644 server/serializer/create_page.go create mode 100644 webapp/src/components/confluence_instance_selector/confluence_instance_selector.jsx create mode 100644 webapp/src/components/confluence_space_selector/confluence_space_selector.jsx create mode 100644 webapp/src/components/create_confluence_page_modal/create_confluence_page_modal.jsx create mode 100644 webapp/src/components/react_select_setting.jsx create mode 100644 webapp/src/components/setting.jsx create mode 100644 webapp/src/utils/styles.js diff --git a/server/client.go b/server/client.go index 1702901..b87a745 100644 --- a/server/client.go +++ b/server/client.go @@ -1,5 +1,7 @@ package main +import "github.com/mattermost/mattermost-plugin-confluence/server/serializer" + // Client is the combined interface for all upstream APIs and convenience methods. type Client interface { RESTService @@ -13,4 +15,6 @@ type RESTService interface { GetSpaceData(string) (*SpaceResponse, error) GetUserGroups(*Connection) ([]*UserGroup, error) GetPageData(pageID int) (*PageResponse, error) + GetSpacesForConfluenceURL() ([]*SpaceForConfluenceURL, error) + CreatePage(string, *serializer.PageDetails) (*CreatePageResponse, error) } diff --git a/server/client_cloud.go b/server/client_cloud.go index a444709..7e3f679 100644 --- a/server/client_cloud.go +++ b/server/client_cloud.go @@ -15,7 +15,9 @@ import ( const ( PathAccessibleResources = "/oauth/token/accessible-resources" - PathGetUserGroupsForCloud = "rest/api/user/memberof?accountId=%s" + PathGetUserGroupsForCloud = "/rest/api/user/memberof?accountId=%s" + PathGetSpacesForCloud = "/rest/api/space?limit=100" + PathCreatePageForCloud = "/rest/api/content" ) type confluenceCloudClient struct { @@ -157,3 +159,44 @@ func (ccc *confluenceCloudClient) GetUserGroups(connection *Connection) ([]*User return userGroups.Groups, nil } + +func (ccc *confluenceCloudClient) GetSpacesForConfluenceURL() ([]*SpaceForConfluenceURL, error) { + spacesForConfluenceURL := SpacesForConfluenceURL{} + url, err := utils.GetEndpointURL(ccc.URL, PathGetSpacesForCloud) + + if err != nil { + return nil, errors.Wrap(err, "confluence GetSpacesForConfluenceURL") + } + _, err = utils.CallJSON(ccc.URL, http.MethodGet, url, nil, &spacesForConfluenceURL, ccc.HTTPClient) + if err != nil { + return nil, errors.Wrap(err, "confluence GetSpacesForConfluenceURL") + } + return spacesForConfluenceURL.Spaces, nil +} + +func (ccc *confluenceCloudClient) CreatePage(spaceKey string, pageDetails *serializer.PageDetails) (*CreatePageResponse, error) { + requestBody := &CreatePageRequestBody{ + Title: pageDetails.Title, + Type: "page", + Space: SpaceForPageCreate{ + Key: spaceKey, + }, + Body: BodyForPageCreate{ + Storage: Storage{ + Value: pageDetails.Description, + Representation: "storage", + }, + }, + } + + createPageResponse := &CreatePageResponse{} + url, err := utils.GetEndpointURL(ccc.URL, PathCreatePageForCloud) + if err != nil { + return nil, errors.Wrap(err, "confluence CreatePage") + } + _, err = utils.CallJSON(ccc.URL, http.MethodPost, url, requestBody, createPageResponse, ccc.HTTPClient) + if err != nil { + return nil, errors.Wrap(err, "confluence CreatePage") + } + return createPageResponse, nil +} diff --git a/server/client_server.go b/server/client_server.go index 284a450..cd946e9 100644 --- a/server/client_server.go +++ b/server/client_server.go @@ -22,6 +22,8 @@ const ( PathDeleteWebhook = "/rest/api/webhooks/%s" PathGetUserGroupsForServer = "/rest/api/user/memberof?username=%s" PathAdminData = "/rest/api/audit/retention" + PathGetSpacesForServer = "/rest/api/space?limit=100" + PathCreatePage = "/rest/api/content" ) const ( @@ -45,6 +47,35 @@ type CreateWebhookRequestBody struct { Active string `json:"active"` Configuration *WebhookConfiguration `json:"configuration"` } +type SpaceForPageCreate struct { + Key string `json:"key"` +} + +type Storage struct { + Value string `json:"value"` + Representation string `json:"representation"` +} + +type BodyForPageCreate struct { + Storage Storage `json:"storage"` +} + +type CreatePageRequestBody struct { + Title string `json:"title"` + Type string `json:"type"` + Space SpaceForPageCreate `json:"space"` + Body BodyForPageCreate `json:"body"` +} + +type CreatePageResponse struct { + Space SpaceResponse `json:"space"` + Links LinksForPageCreate `json:"_links"` +} + +type LinksForPageCreate struct { + Self string `json:"webui"` + BaseURL string `json:"base"` +} type SpaceResponse struct { ID int64 `json:"id"` @@ -254,3 +285,42 @@ func (csc *confluenceServerClient) GetUserGroups(connection *Connection) ([]*Use return userGroups.Groups, nil } + +func (csc *confluenceServerClient) GetSpacesForConfluenceURL() ([]*SpaceForConfluenceURL, error) { + spacesForConfluenceURL := SpacesForConfluenceURL{} + url, err := utils.GetEndpointURL(csc.URL, PathGetSpacesForServer) + if err != nil { + return nil, errors.Wrap(err, "confluence GetSpacesForConfluenceURL") + } + _, err = utils.CallJSON(csc.URL, http.MethodGet, url, nil, &spacesForConfluenceURL, csc.HTTPClient) + if err != nil { + return nil, errors.Wrap(err, "confluence GetSpacesForConfluenceURL") + } + return spacesForConfluenceURL.Spaces, nil +} + +func (csc *confluenceServerClient) CreatePage(spaceKey string, pageDetails *serializer.PageDetails) (*CreatePageResponse, error) { + requestBody := &CreatePageRequestBody{ + Title: pageDetails.Title, + Type: "page", + Space: SpaceForPageCreate{ + Key: spaceKey, + }, + Body: BodyForPageCreate{ + Storage: Storage{ + Value: pageDetails.Description, + Representation: "storage", + }, + }, + } + createPageResponse := &CreatePageResponse{} + url, err := utils.GetEndpointURL(csc.URL, PathCreatePage) + if err != nil { + return nil, errors.Wrap(err, "confluence CreatePage") + } + _, err = utils.CallJSON(csc.URL, http.MethodPost, url, requestBody, createPageResponse, csc.HTTPClient) + if err != nil { + return nil, errors.Wrap(err, "confluence CreatePage") + } + return createPageResponse, nil +} diff --git a/server/create_page.go b/server/create_page.go new file mode 100644 index 0000000..6eea087 --- /dev/null +++ b/server/create_page.go @@ -0,0 +1,69 @@ +package main + +import ( + "fmt" + "io/ioutil" + "net/http" + + "github.com/gorilla/mux" + "github.com/mattermost/mattermost-server/v6/model" + + "github.com/mattermost/mattermost-plugin-confluence/server/config" + "github.com/mattermost/mattermost-plugin-confluence/server/serializer" + "github.com/mattermost/mattermost-plugin-confluence/server/utils/types" +) + +const ( + pageCreateSuccess = "You created a page [%s](%s) in space [%s](%s)" +) + +func (p *Plugin) handleCreatePage(w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + spaceKey := params["spaceKey"] + channelID := params["channelID"] + userID := r.Header.Get(config.HeaderMattermostUserID) + + instance, err := p.getInstanceFromURL(r.URL.Path) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + conn, err := p.userStore.LoadConnection(types.ID(instance.GetURL()), types.ID(userID)) + if err != nil { + p.LogAndRespondError(w, http.StatusInternalServerError, "error in loading connection.", err) + return + } + + client, err := instance.GetClient(conn) + if err != nil { + p.LogAndRespondError(w, http.StatusInternalServerError, "not able to get Client.", err) + return + } + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + pageDetails, err := serializer.PageDetailsFromJSON(body) + if err != nil { + p.LogAndRespondError(w, http.StatusInternalServerError, "error decoding request body for page details.", err) + return + } + + createPageResponse, err := client.CreatePage(spaceKey, pageDetails) + if err != nil { + p.LogAndRespondError(w, http.StatusInternalServerError, "not able to create page.", err) + return + } + + post := &model.Post{ + UserId: p.conf.botUserID, + ChannelId: channelID, + Message: fmt.Sprintf(pageCreateSuccess, pageDetails.Title, fmt.Sprintf("%s%s", createPageResponse.Links.BaseURL, createPageResponse.Links.Self), spaceKey, fmt.Sprintf("%s%s", createPageResponse.Links.BaseURL, createPageResponse.Space.Links.Self)), + } + _ = p.API.SendEphemeralPost(userID, post) + ReturnStatusOK(w) +} diff --git a/server/get_spaces.go b/server/get_spaces.go new file mode 100644 index 0000000..8acf934 --- /dev/null +++ b/server/get_spaces.go @@ -0,0 +1,40 @@ +package main + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost-plugin-confluence/server/config" + "github.com/mattermost/mattermost-plugin-confluence/server/utils/types" +) + +func (p *Plugin) handleGetSpacesForConfluenceURL(w http.ResponseWriter, r *http.Request) { + userID := r.Header.Get(config.HeaderMattermostUserID) + + instance, err := p.getInstanceFromURL(r.URL.Path) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + conn, err := p.userStore.LoadConnection(types.ID(instance.GetURL()), types.ID(userID)) + if err != nil { + p.LogAndRespondError(w, http.StatusInternalServerError, "error in loading connection.", err) + return + } + + client, err := instance.GetClient(conn) + if err != nil { + p.LogAndRespondError(w, http.StatusInternalServerError, "not able to get Client.", err) + return + } + + spaces, err := client.GetSpacesForConfluenceURL() + if err != nil { + p.LogAndRespondError(w, http.StatusInternalServerError, "not able to get Spaces for confluence url.", err) + return + } + b, _ := json.Marshal(spaces) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(string(b))) +} diff --git a/server/http.go b/server/http.go index f972f05..7c75cbb 100644 --- a/server/http.go +++ b/server/http.go @@ -72,6 +72,8 @@ func (p *Plugin) InitAPI() *mux.Router { apiRouter.HandleFunc(routeUserConnect, p.httpOAuth2Connect).Methods(http.MethodGet) apiRouter.HandleFunc(routeUserComplete, p.httpOAuth2Complete).Methods(http.MethodGet) + apiRouter.HandleFunc("/spaces", p.handleGetSpacesForConfluenceURL).Methods(http.MethodGet) + apiRouter.HandleFunc("/{channelID:[A-Za-z0-9]+}/spaceKey/{spaceKey:.+}/createpage", p.handleCreatePage).Methods(http.MethodPost) apiRouter.HandleFunc("/{channelID:[A-Za-z0-9]+}/subscription/{type:[A-Za-z_]+}", p.handleSaveSubscription).Methods(http.MethodPost) apiRouter.HandleFunc("/{channelID:[A-Za-z0-9]+}/subscription/{type:[A-Za-z_]+}/{oldSubscriptionType:[A-Za-z_]+}", p.handleEditChannelSubscription).Methods(http.MethodPut) apiRouter.HandleFunc("/server/webhook/{userID:.+}", p.handleConfluenceServerWebhook).Methods(http.MethodPost) diff --git a/server/serializer/create_page.go b/server/serializer/create_page.go new file mode 100644 index 0000000..8ee50bf --- /dev/null +++ b/server/serializer/create_page.go @@ -0,0 +1,16 @@ +package serializer + +import "encoding/json" + +type PageDetails struct { + Title string `json:"title"` + Description string `json:"description"` +} + +func PageDetailsFromJSON(body []byte) (*PageDetails, error) { + var pd PageDetails + if err := json.Unmarshal(body, &pd); err != nil { + return nil, err + } + return &pd, nil +} diff --git a/server/user.go b/server/user.go index e6ab145..5f7d831 100644 --- a/server/user.go +++ b/server/user.go @@ -41,6 +41,15 @@ type UserGroup struct { Name string `json:"name"` } +type SpacesForConfluenceURL struct { + Spaces []*SpaceForConfluenceURL `json:"results,omitempty"` +} + +type SpaceForConfluenceURL struct { + Key string `json:"key"` + Name string `json:"name"` +} + type Connection struct { ConfluenceUser PluginVersion string diff --git a/webapp/src/actions/index.js b/webapp/src/actions/index.js index 768fa7b..a380f78 100644 --- a/webapp/src/actions/index.js +++ b/webapp/src/actions/index.js @@ -1,5 +1,5 @@ import { - closeSubscriptionModal, openSubscriptionModal, saveChannelSubscription, editChannelSubscription, getChannelSubscription, getConnected, + closeSubscriptionModal, openSubscriptionModal, saveChannelSubscription, editChannelSubscription, getChannelSubscription, getConnected, openCreateConfluencePageModal, closeCreateConfluencePageModal, getSpacesForConfluenceURL, createPageForConfluence, } from './subscription_modal'; export { @@ -9,4 +9,8 @@ export { editChannelSubscription, getChannelSubscription, getConnected, + openCreateConfluencePageModal, + closeCreateConfluencePageModal, + getSpacesForConfluenceURL, + createPageForConfluence, }; diff --git a/webapp/src/actions/subscription_modal.js b/webapp/src/actions/subscription_modal.js index 56ec8ff..6d66f29 100644 --- a/webapp/src/actions/subscription_modal.js +++ b/webapp/src/actions/subscription_modal.js @@ -75,6 +75,64 @@ export const closeSubscriptionModal = () => (dispatch) => { }); }; +export function openCreateConfluencePageModal(postID) { + return async (dispatch) => { + let data = null; + try { + data = await await Client.getPostDetails(postID); + } catch (error) { + return {error}; + } + dispatch({ + type: Constants.ACTION_TYPES.OPEN_CREATE_CONFLUENCE_PAGE_MODAL, + data, + }); + return {data}; + }; +} + +export const createPageForConfluence = (instanceID, channelID, spaceKey, pageDetials) => { + return async () => { + let data = null; + try { + data = await Client.createPage(instanceID, channelID, spaceKey, pageDetials); + } catch (error) { + return { + data, + error, + }; + } + + return { + data, + error: null, + }; + }; +}; + +export const closeCreateConfluencePageModal = () => (dispatch) => { + dispatch({ + type: Constants.ACTION_TYPES.CLOSE_CREATE_CONFLUENCE_PAGE_MODAL, + }); +}; + +export function getSpacesForConfluenceURL(instanceID) { + return async (dispatch) => { + let data = null; + try { + data = await Client.getSpacesForConfluenceURL(instanceID); + } catch (error) { + return {error}; + } + dispatch({ + type: Constants.ACTION_TYPES.RECEIVED_CONFLUENCE_INSTANCE, + data, + }); + + return {data}; + }; +} + export const getChannelSubscription = (channelID, alias, userID) => async (dispatch) => { try { const response = await Client.getChannelSubscription(channelID, alias); diff --git a/webapp/src/client/client.js b/webapp/src/client/client.js index 6545a11..471fb29 100644 --- a/webapp/src/client/client.js +++ b/webapp/src/client/client.js @@ -35,6 +35,21 @@ export default class Client { return this.doGet(url); }; + getSpacesForConfluenceURL = (instanceID) => { + const url = `${this.pluginApiUrl}/instance/${encodeToBase64(instanceID)}/spaces`; + return this.doGet(url); + }; + + getPostDetails = (postID) => { + const url = `${this.baseUrl}/api/v4/posts/${postID}`; + return this.doGet(url); + }; + + createPage = (instanceID, channelID, spaceKey, pageDetials) => { + const url = `${this.pluginApiUrl}/instance/${encodeToBase64(instanceID)}/${channelID}/spaceKey/${spaceKey}/createpage`; + return this.doPost(url, pageDetials); + }; + getConnected = () => { const url = `${this.pluginApiUrl}/userinfo`; return this.doGet(url); diff --git a/webapp/src/components/confluence_field.jsx b/webapp/src/components/confluence_field.jsx index b27c416..2be6557 100644 --- a/webapp/src/components/confluence_field.jsx +++ b/webapp/src/components/confluence_field.jsx @@ -20,6 +20,7 @@ export default class ConfluenceField extends React.PureComponent { removeValidation: PropTypes.func.isRequired, theme: PropTypes.object, fieldType: PropTypes.string.isRequired, + type: PropTypes.string, readOnly: PropTypes.bool, formGroupStyle: PropTypes.object, formControlStyle: PropTypes.object, @@ -77,7 +78,7 @@ export default class ConfluenceField extends React.PureComponent { render() { const { - required, fieldType, theme, label, formGroupStyle, formControlStyle, + required, fieldType, theme, label, formGroupStyle, formControlStyle, type, } = this.props; const requiredErrorMsg = 'This field is required.'; let requiredError = null; @@ -89,7 +90,16 @@ export default class ConfluenceField extends React.PureComponent { ); } let field = null; - if (fieldType === 'input') { + if (fieldType === 'input' && type === 'textarea') { + field = ( + + ); + } else if (fieldType === 'input' && type === 'text') { field = ( { + const validator = new Validator(); + + const isInstalledInstances = useSelector((state) => selectors.isInstalledInstances(state)); + + const [installedInstancesOptions, setInstalledInstancesOptions] = useState([]); + + useEffect(() => { + const issueOptions = isInstalledInstances?.map((it) => ({label: it.instance_id, value: it.instance_id})); + setInstalledInstancesOptions(issueOptions); + }, [isInstalledInstances]); + + const handleEvents = (_, instanceID) => { + if (instanceID === props.selectedInstanceID) { + return; + } + props.onInstanceChange(instanceID); + }; + + return ( + + option.value === props.selectedInstanceID)} + required={true} + theme={props.theme} + addValidate={validator.addComponent} + removeValidate={validator.removeComponent} + /> + + ); +}; + +ConfluenceInstanceSelector.propTypes = { + theme: PropTypes.object.isRequired, + selectedInstanceID: PropTypes.string.isRequired, + onInstanceChange: PropTypes.func.isRequired, +}; + +export default ConfluenceInstanceSelector; diff --git a/webapp/src/components/confluence_space_selector/confluence_space_selector.jsx b/webapp/src/components/confluence_space_selector/confluence_space_selector.jsx new file mode 100644 index 0000000..6826e6f --- /dev/null +++ b/webapp/src/components/confluence_space_selector/confluence_space_selector.jsx @@ -0,0 +1,54 @@ +import React, {useEffect, useState} from 'react'; +import {useSelector} from 'react-redux'; + +import PropTypes from 'prop-types'; + +import Validator from '../validator'; +import ReactSelectSetting from '../react_select_setting'; +import selectors from 'src/selectors'; + +const ConfluenceSpaceSelector = (props) => { + const validator = new Validator(); + + const spacesForConfluenceURL = useSelector((state) => selectors.spacesForConfluenceURL(state)); + + const [spaceOptions, setSpaceOptions] = useState([]); + + useEffect(() => { + if (spacesForConfluenceURL && spacesForConfluenceURL?.spaces) { + const issueOptions = spacesForConfluenceURL?.spaces.map((it) => ({label: it.name, value: it.key})); + setSpaceOptions(issueOptions); + } + }, [spacesForConfluenceURL]); + + const handleEvents = (_, spaceKey) => { + if (spaceKey === props.selectedSpaceKey) { + return; + } + props.onSpaceKeyChange(spaceKey); + }; + + return ( + + option.value === props.selectedSpaceKey)} + required={true} + theme={props.theme} + addValidate={validator.addComponent} + removeValidate={validator.removeComponent} + /> + + ); +}; + +ConfluenceSpaceSelector.propTypes = { + theme: PropTypes.object.isRequired, + selectedSpaceKey: PropTypes.string.isRequired, + onSpaceKeyChange: PropTypes.func.isRequired, +}; + +export default ConfluenceSpaceSelector; diff --git a/webapp/src/components/create_confluence_page_modal/create_confluence_page_modal.jsx b/webapp/src/components/create_confluence_page_modal/create_confluence_page_modal.jsx new file mode 100644 index 0000000..dae5223 --- /dev/null +++ b/webapp/src/components/create_confluence_page_modal/create_confluence_page_modal.jsx @@ -0,0 +1,202 @@ +import React, {useEffect, useState} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; +import {Modal, Button} from 'react-bootstrap'; + +import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common'; + +import selectors from 'src/selectors'; +import ConfluenceInstanceSelector from '../confluence_instance_selector/confluence_instance_selector'; +import ConfluenceSpaceSelector from '../confluence_space_selector/confluence_space_selector'; +import Validator from '../validator'; +import ConfluenceField from '../confluence_field'; +import {getModalStyles} from 'src/utils/styles'; +import {getSpacesForConfluenceURL, createPageForConfluence, closeCreateConfluencePageModal} from 'src/actions'; + +const getStyle = { + typeFormControl: { + resize: 'none', + height: '10em', + }, +}; + +const CreateConfluencePage = (theme) => { + const dispatch = useDispatch(); + const validator = new Validator(); + + const createConfluencePageModalVisible = useSelector((state) => selectors.isCreateConfluencePageModalVisible(state)); + const channelID = useSelector((state) => getCurrentChannelId(state)); + + const [isCreateConfluencePageModalVisible, setIsCreateConfluencePageModalVisible] = useState(false); + const [instanceID, setInstanceID] = useState(''); + const [pageTitle, setPageTitle] = useState(''); + const [pageDescription, setPageDescription] = useState(createConfluencePageModalVisible.message); + const [spaceKey, setSpaceKey] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(false); + + useEffect(() => { + if (createConfluencePageModalVisible && createConfluencePageModalVisible.message) { + setIsCreateConfluencePageModalVisible(true); + setPageDescription(createConfluencePageModalVisible.message); + } else { + setIsCreateConfluencePageModalVisible(false); + } + }, [createConfluencePageModalVisible]); + + useEffect(() => { + if (instanceID !== '') { + let response; + (async () => { + response = await getSpacesForConfluenceURL(instanceID)(dispatch); + if (response?.error !== null) { + setError(response.error.response?.text); + } + })(); + setSpaceKey(''); + } + }, [instanceID, dispatch]); + + const handleClose = (e) => { + if (e && e.preventDefault) { + e.preventDefault(); + } + setSaving(false); + setInstanceID(''); + setSpaceKey(''); + setPageTitle(''); + setPageDescription(''); + setError(''); + dispatch(closeCreateConfluencePageModal()); + }; + + const handleInstanceChange = (currentInstanceID) => { + setInstanceID(currentInstanceID); + setSpaceKey(''); + setError(''); + }; + + const handleSpaceKeyChange = (currentSpaceKey) => { + setSpaceKey(currentSpaceKey); + }; + + const handlePageTitle = (e) => { + setPageTitle(e.target.value); + }; + + const handlePageDescription = (e) => { + setPageDescription(e.target.value); + }; + + const handleSubmit = () => { + if (!validator.validate()) { + return; + } + + const pageDetials = { + title: pageTitle, + description: pageDescription, + }; + + setSaving(true); + (async () => { + const response = await createPageForConfluence(instanceID, channelID, spaceKey, pageDetials)(dispatch); + if (response?.error) { + setError(response.error?.response?.text); + setSaving(false); + } else { + setSaving(false); + setInstanceID(''); + setSpaceKey(''); + setPageTitle(''); + setPageDescription(''); + setError(''); + dispatch(closeCreateConfluencePageModal()); + } + })(); + }; + + return ( + + + + {'Create Confluence Page'} + + + + + + {instanceID !== '' && + } + + {spaceKey !== '' && + } + {spaceKey !== '' && + } + {error && +

+ + {error} +

} +
+ + {spaceKey !== '' && + + + } +
+ ); +}; + +export default CreateConfluencePage; diff --git a/webapp/src/components/react_select_setting.jsx b/webapp/src/components/react_select_setting.jsx new file mode 100644 index 0000000..6b3450c --- /dev/null +++ b/webapp/src/components/react_select_setting.jsx @@ -0,0 +1,157 @@ +// 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 ReactSelect from 'react-select'; +import AsyncSelect from 'react-select/async'; +import CreatableSelect from 'react-select/creatable'; + +import {getStyleForReactSelect} from 'src/utils/styles'; + +import Setting from './setting'; + +const MAX_NUM_OPTIONS = 100; + +export default class ReactSelectSetting extends React.PureComponent { + static propTypes = { + name: PropTypes.string, + onChange: PropTypes.func, + theme: PropTypes.object.isRequired, + isClearable: PropTypes.bool, + options: PropTypes.array.isRequired, + value: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.array, + PropTypes.string, + ]), + addValidate: PropTypes.func, + removeValidate: PropTypes.func, + required: PropTypes.bool, + allowUserDefinedValue: PropTypes.bool, + limitOptions: PropTypes.bool, + resetInvalidOnChange: PropTypes.bool, + }; + + constructor(props) { + super(props); + + this.state = {invalid: false}; + } + + componentDidMount() { + if (this.props.addValidate && this.props.name) { + this.props.addValidate(this.props.name, this.isValid); + } + } + + componentWillUnmount() { + if (this.props.removeValidate && this.props.name) { + this.props.removeValidate(this.props.name); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevState.invalid && (this.props.value && this.props.value.value) !== (prevProps.value && prevProps.value.value)) { + this.setState({invalid: false}); //eslint-disable-line react/no-did-update-set-state + } + } + + handleChange = (value) => { + if (this.props.onChange) { + if (Array.isArray(value)) { + this.props.onChange(this.props.name, value.map((x) => x.value)); + } else { + const newValue = value ? value.value : null; + this.props.onChange(this.props.name, newValue); + } + } + + if (this.props.resetInvalidOnChange) { + this.setState({invalid: false}); + } + }; + + // Standard search term matching plus reducing to < 100 items + filterOptions = (input) => { + let options = this.props.options; + if (input) { + options = options.filter((x) => x.label.toLowerCase().includes(input.toLowerCase())); + } + return Promise.resolve(options.slice(0, MAX_NUM_OPTIONS)); + }; + + isValid = () => { + if (!this.props.required) { + return true; + } + + const valid = Boolean(this.props.value); + + this.setState({invalid: !valid}); + return valid; + }; + + render() { + const requiredMsg = 'This field is required.'; + let validationError = null; + if (this.props.required && this.state.invalid) { + validationError = ( +

+ {requiredMsg} +

+ ); + } + + let selectComponent = null; + if (this.props.limitOptions && this.props.options.size > MAX_NUM_OPTIONS) { + // The parent component has let us know that we may have a large number of options, and that + // the dataset is static. In this case, we use the AsyncSelect component and synchronous func + // this.filterOptions() to limit the number of options being rendered at a given time. + selectComponent = ( + + ); + } else if (this.props.allowUserDefinedValue) { + selectComponent = ( + 'Start typing...'} + placeholder='' + menuPortalTarget={document.body} + menuPlacement='auto' + onChange={this.handleChange} + styles={getStyleForReactSelect(this.props.theme)} + /> + ); + } else { + selectComponent = ( + + ); + } + + return ( + + {selectComponent} + {validationError} + + ); + } +} \ No newline at end of file diff --git a/webapp/src/components/setting.jsx b/webapp/src/components/setting.jsx new file mode 100644 index 0000000..736b1bf --- /dev/null +++ b/webapp/src/components/setting.jsx @@ -0,0 +1,60 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class Setting extends React.PureComponent { + static propTypes = { + inputId: PropTypes.string, + label: PropTypes.node, + children: PropTypes.node.isRequired, + helpText: PropTypes.node, + required: PropTypes.bool, + hideRequiredStar: PropTypes.bool, + }; + + constructor(props) { + super(props); + this.state = {}; + } + + render() { + const { + children, + helpText, + inputId, + label, + required, + hideRequiredStar, + } = this.props; + + return ( +
+ {label && ( + ) + } + {required && !hideRequiredStar && ( + + {'*'} + + ) + } +
+ {children} +
+ {helpText} +
+
+
+ ); + } +} diff --git a/webapp/src/constants/action_types.js b/webapp/src/constants/action_types.js index 18b00b5..a6303a3 100644 --- a/webapp/src/constants/action_types.js +++ b/webapp/src/constants/action_types.js @@ -3,7 +3,10 @@ import {id} from '../manifest'; export const ACTION_TYPES = { OPEN_SUBSCRIPTION_MODAL: id + '_open_subscription_modal', CLOSE_SUBSCRIPTION_MODAL: id + '_close_subscription_modal', + OPEN_CREATE_CONFLUENCE_PAGE_MODAL: id + '_open_create_confluence_page_modal', + CLOSE_CREATE_CONFLUENCE_PAGE_MODAL: id + '_close_create_confluence_page_modal', RECEIVED_SUBSCRIPTION: id + '_received_subscription', RECEIVED_CONNECTED: id + '_connected', RECEIVED_INSTANCE_STATUS: id + '_instance_status', + RECEIVED_CONFLUENCE_INSTANCE: id + '_received_confluence_instance', }; diff --git a/webapp/src/hooks/index.js b/webapp/src/hooks/index.js index 78e8004..93ed0f8 100644 --- a/webapp/src/hooks/index.js +++ b/webapp/src/hooks/index.js @@ -1,6 +1,6 @@ import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; -import {openSubscriptionModal, getChannelSubscription, getConnected} from '../actions'; +import {openSubscriptionModal, getChannelSubscription, getConnected, openCreateConfluencePageModal} from '../actions'; import {splitArgs} from '../utils'; import {sendEphemeralPost} from '../actions/subscription_modal'; @@ -45,4 +45,10 @@ export default class Hooks { args: contextArgs, }); } + + createConfluencePage = (message) => { + this.store.dispatch(getConnected()); + this.store.dispatch(openCreateConfluencePageModal(message)); + return Promise.resolve({}); + } } diff --git a/webapp/src/index.js b/webapp/src/index.js index a002634..c9f39ae 100644 --- a/webapp/src/index.js +++ b/webapp/src/index.js @@ -4,6 +4,7 @@ import Hooks from './hooks'; import reducer from './reducers'; import SubscriptionModal from './components/subscription_modal'; +import CreateConfluencePage from './components/create_confluence_page_modal/create_confluence_page_modal'; // // Define the plugin class that will register @@ -12,9 +13,11 @@ import SubscriptionModal from './components/subscription_modal'; class PluginClass { initialize(registry, store) { registry.registerReducer(reducer); + registry.registerRootComponent(CreateConfluencePage); registry.registerRootComponent(SubscriptionModal); const hooks = new Hooks(store); registry.registerSlashCommandWillBePostedHook(hooks.slashCommandWillBePostedHook); + registry.registerPostDropdownMenuAction('Create Confluence Page', hooks.createConfluencePage); } } diff --git a/webapp/src/reducers/index.js b/webapp/src/reducers/index.js index 3d523d3..060312c 100644 --- a/webapp/src/reducers/index.js +++ b/webapp/src/reducers/index.js @@ -1,10 +1,12 @@ import {combineReducers} from 'redux'; -import {subscriptionModal, subscriptionEditModal, installedInstances, userConnected} from './subscription_modal'; +import {subscriptionModal, subscriptionEditModal, installedInstances, userConnected, createConfluencePageModal, spacesForConfluenceURL} from './subscription_modal'; export default combineReducers({ subscriptionModal, subscriptionEditModal, installedInstances, userConnected, + createConfluencePageModal, + spacesForConfluenceURL, }); diff --git a/webapp/src/reducers/subscription_modal.js b/webapp/src/reducers/subscription_modal.js index 9d34a20..25f6c36 100644 --- a/webapp/src/reducers/subscription_modal.js +++ b/webapp/src/reducers/subscription_modal.js @@ -29,6 +29,30 @@ export const subscriptionEditModal = (state = {}, action) => { } }; +export const spacesForConfluenceURL = (state = {}, action) => { + switch (action.type) { + case Constants.ACTION_TYPES.RECEIVED_CONFLUENCE_INSTANCE: + return { + spaces: action.data ? action.data : [], + }; + default: + return state; + } +}; + +export const createConfluencePageModal = (state = {}, action) => { + switch (action.type) { + case Constants.ACTION_TYPES.OPEN_CREATE_CONFLUENCE_PAGE_MODAL: + return { + message: action.data.message, + }; + case Constants.ACTION_TYPES.CLOSE_CREATE_CONFLUENCE_PAGE_MODAL: + return {}; + default: + return state; + } +}; + export function installedInstances(state = [], action) { // We're notified of the instance status at startup (through getConnected) // and when we get a websocket instance_status event diff --git a/webapp/src/selectors/index.js b/webapp/src/selectors/index.js index b4e4681..c9876e0 100644 --- a/webapp/src/selectors/index.js +++ b/webapp/src/selectors/index.js @@ -6,7 +6,19 @@ const isSubscriptionModalVisible = (state) => getPluginState(state).subscription const isSubscriptionEditModalVisible = (state) => getPluginState(state).subscriptionEditModal; +const isCreateConfluencePageModalVisible = (state) => getPluginState(state).createConfluencePageModal; + +const isInstalledInstances = (state) => getPluginState(state).installedInstances; + +const spacesForConfluenceURL = (state) => getPluginState(state).spacesForConfluenceURL; + +const isUserConnected = (state) => getPluginState(state).userConnected; + export default { isSubscriptionModalVisible, isSubscriptionEditModalVisible, + isCreateConfluencePageModalVisible, + isUserConnected, + isInstalledInstances, + spacesForConfluenceURL, }; diff --git a/webapp/src/utils/styles.js b/webapp/src/utils/styles.js new file mode 100644 index 0000000..3755423 --- /dev/null +++ b/webapp/src/utils/styles.js @@ -0,0 +1,119 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {changeOpacity} from 'mattermost-redux/utils/theme_utils'; + +export const getStyleForReactSelect = (theme) => { + if (!theme) { + return null; + } + + return { + menuPortal: (provided) => ({ + ...provided, + zIndex: 9999, + }), + control: (provided, state) => ({ + ...provided, + color: theme.theme.centerChannelColor, + background: theme.theme.centerChannelBg, + + // Overwrittes the different states of border + borderColor: state.isFocused ? changeOpacity(theme.theme.centerChannelColor, 0.25) : changeOpacity(theme.theme.centerChannelColor, 0.12), + + // Removes weird border around container + boxShadow: 'inset 0 1px 1px ' + changeOpacity(theme.theme.centerChannelColor, 0.075), + borderRadius: '2px', + + '&:hover': { + borderColor: changeOpacity(theme.theme.centerChannelColor, 0.25), + }, + }), + option: (provided, state) => ({ + ...provided, + background: state.isFocused ? changeOpacity(theme.theme.centerChannelColor, 0.12) : theme.theme.centerChannelBg, + cursor: state.isDisabled ? 'not-allowed' : 'pointer', + color: theme.theme.centerChannelColor, + '&:hover': state.isDisabled ? {} : { + background: changeOpacity(theme.theme.centerChannelColor, 0.12), + }, + }), + clearIndicator: (provided) => ({ + ...provided, + width: '34px', + color: changeOpacity(theme.theme.centerChannelColor, 0.4), + transform: 'scaleX(1.15)', + marginRight: '-10px', + '&:hover': { + color: theme.theme.centerChannelColor, + }, + }), + multiValue: (provided) => ({ + ...provided, + background: changeOpacity(theme.theme.centerChannelColor, 0.15), + }), + multiValueLabel: (provided) => ({ + ...provided, + color: theme.theme.centerChannelColor, + paddingBottom: '4px', + paddingLeft: '8px', + fontSize: '90%', + }), + multiValueRemove: (provided) => ({ + ...provided, + transform: 'translateX(-2px) scaleX(1.15)', + color: changeOpacity(theme.theme.centerChannelColor, 0.4), + '&:hover': { + background: 'transparent', + }, + }), + menu: (provided) => ({ + ...provided, + color: theme.theme.centerChannelColor, + background: theme.theme.centerChannelBg, + border: '1px solid ' + changeOpacity(theme.theme.centerChannelColor, 0.2), + borderRadius: '0 0 2px 2px', + boxShadow: changeOpacity(theme.theme.centerChannelColor, 0.2) + ' 1px 3px 12px', + marginTop: '4px', + }), + input: (provided) => ({ + ...provided, + color: theme.theme.centerChannelColor, + }), + placeholder: (provided) => ({ + ...provided, + color: theme.theme.centerChannelColor, + }), + dropdownIndicator: (provided) => ({ + ...provided, + + '&:hover': { + color: theme.theme.centerChannelColor, + }, + }), + singleValue: (provided) => ({ + ...provided, + color: theme.theme.centerChannelColor, + }), + indicatorSeparator: (provided) => ({ + ...provided, + display: 'none', + }), + }; +}; + +export const getModalStyles = (theme) => ({ + modalBody: { + padding: '2em 2em 3em', + color: theme.centerChannelColor, + backgroundColor: theme.centerChannelBg, + }, + modalFooter: { + padding: '2rem 15px', + }, + descriptionArea: { + height: 'auto', + width: '100%', + color: '#000', + }, +});