From 8d21f7e6d8cab8017558d2d3c8f57fa9c0b5f886 Mon Sep 17 00:00:00 2001 From: Abhishek Verma <72438220+avas27JTG@users.noreply.github.com> Date: Thu, 18 Aug 2022 15:07:09 +0530 Subject: [PATCH 1/8] [MI-1987]: Integrated project list UI (#28) * [MI-1986]: Create plugin API to fetch linked projects list * [MI-1987]: Integrated project list UI * [MI-1987]: Review fixes * [MI-1987]: Review fix * [MI-1987]: Review fixes * [MI-2001]: Create plugin API to unlink project and integration (#29) * [MI-2001]: [MI-2001]: Create plugin API to unlink project and integrate the UI * [MI-2001]: Review fixes * [MI-2002]: Review fixes * [MI-2001]: Review fixes * [MI-2001]: Review fixes * [MI-2001]: Review fix Co-authored-by: Abhishek Verma Co-authored-by: Abhishek Verma --- server/constants/messages.go | 3 + server/constants/routes.go | 11 +- server/plugin/api.go | 77 ++++++- server/serializers/{error.go => common.go} | 4 + .../components/buttons/iconButton/index.tsx | 3 +- webapp/src/components/card/project/index.tsx | 16 +- .../src/components/card/project/styles.scss | 16 -- .../components/card/subscription/styles.scss | 8 - webapp/src/components/emptyState/index.tsx | 51 +++++ .../emptyState}/styles.scss | 19 +- .../src/components/labelValuePair/index.tsx | 2 +- .../modal/confirmationModal/index.tsx | 28 +++ webapp/src/components/modal/index.tsx | 6 +- .../modal/subComponents/modalFooter/index.tsx | 9 +- .../subComponents/modalFooter/styles.scss | 5 + .../modal/subComponents/modalHeader/index.tsx | 2 +- webapp/src/containers/LinkModal/index.tsx | 196 ++++++++---------- webapp/src/containers/Rhs/index.tsx | 12 +- .../src/containers/Rhs/projectList/index.tsx | 120 ++++++++--- .../containers/Rhs/tabs/connections/.gitkeep | 0 .../containers/Rhs/tabs/connections/index.tsx | 17 -- webapp/src/containers/Rhs/tabs/index.tsx | 31 --- .../src/containers/Rhs/tabs/no_data/index.tsx | 39 ---- webapp/src/containers/Rhs/tabs/styles.scss | 39 ---- .../Rhs/tabs/subscriptions/index.tsx | 18 -- webapp/src/hooks/usePluginApi.ts | 7 +- webapp/src/index.tsx | 2 +- webapp/src/plugin_constants/index.ts | 10 + webapp/src/reducers/linkModal/index.ts | 21 +- webapp/src/reducers/projectDetails/index.ts | 21 +- webapp/src/selectors/index.tsx | 12 +- webapp/src/services/index.ts | 19 +- webapp/src/styles/_components.scss | 7 + webapp/src/styles/_global.scss | 8 + webapp/src/styles/_utils.scss | 2 +- webapp/src/types/common/index.d.ts | 20 +- webapp/src/types/common/store.d.ts | 6 + webapp/src/utils/index.ts | 2 +- 38 files changed, 489 insertions(+), 380 deletions(-) rename server/serializers/{error.go => common.go} (65%) delete mode 100644 webapp/src/components/card/project/styles.scss create mode 100644 webapp/src/components/emptyState/index.tsx rename webapp/src/{containers/Rhs/tabs/no_data => components/emptyState}/styles.scss (54%) create mode 100644 webapp/src/components/modal/confirmationModal/index.tsx delete mode 100644 webapp/src/containers/Rhs/tabs/connections/.gitkeep delete mode 100644 webapp/src/containers/Rhs/tabs/connections/index.tsx delete mode 100644 webapp/src/containers/Rhs/tabs/index.tsx delete mode 100644 webapp/src/containers/Rhs/tabs/no_data/index.tsx delete mode 100644 webapp/src/containers/Rhs/tabs/styles.scss delete mode 100644 webapp/src/containers/Rhs/tabs/subscriptions/index.tsx create mode 100644 webapp/src/types/common/store.d.ts diff --git a/server/constants/messages.go b/server/constants/messages.go index 580e17e5..9afcf2cf 100644 --- a/server/constants/messages.go +++ b/server/constants/messages.go @@ -25,4 +25,7 @@ const ( AlreadyLinkedProject = "This project is already linked." GetProjectListError = "Error getting Project List" ErrorFetchProjectList = "Error in fetching project list" + ErrorDecodingBody = "Error in decoding body" + ProjectNotFound = "Requested project does not exist" + ErrorUnlinkProject = "Error in unlinking the project" ) diff --git a/server/constants/routes.go b/server/constants/routes.go index 18f1a390..74ff53cb 100644 --- a/server/constants/routes.go +++ b/server/constants/routes.go @@ -2,9 +2,10 @@ package constants const ( // Plugin API Routes - APIPrefix = "/api/v1" - WildRoute = "{anything:.*}" - PathOAuthConnect = "/oauth/connect" - PathOAuthCallback = "/oauth/complete" - PathGetAllLinkedProjects = "/link/project" + APIPrefix = "/api/v1" + WildRoute = "{anything:.*}" + PathOAuthConnect = "/oauth/connect" + PathOAuthCallback = "/oauth/complete" + PathLinkedProjects = "/link/project" + PathUnlinkProject = "/unlink/project" ) diff --git a/server/plugin/api.go b/server/plugin/api.go index 5fbce826..4d041000 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -36,7 +36,8 @@ func (p *Plugin) InitRoutes() { // Plugin APIs s.HandleFunc("/tasks", p.handleAuthRequired(p.handleCreateTask)).Methods(http.MethodPost) s.HandleFunc("/link", p.handleAuthRequired(p.handleLink)).Methods(http.MethodPost) - s.HandleFunc(constants.PathGetAllLinkedProjects, p.handleAuthRequired(p.handleGetAllLinkedProjects)).Methods(http.MethodGet) + s.HandleFunc(constants.PathLinkedProjects, p.handleAuthRequired(p.handleGetAllLinkedProjects)).Methods(http.MethodGet) + s.HandleFunc(constants.PathUnlinkProject, p.handleAuthRequired(p.handleUnlinkProject)).Methods(http.MethodPost) } // API to create task of a project in an organization. @@ -45,7 +46,7 @@ func (p *Plugin) handleCreateTask(w http.ResponseWriter, r *http.Request) { body, err := serializers.CreateTaskRequestPayloadFromJSON(r.Body) if err != nil { - p.API.LogError("Error in decoding the body for creating a task", "Error", err.Error()) + p.API.LogError(constants.ErrorDecodingBody, "Error", err.Error()) p.handleError(w, r, &serializers.Error{Code: http.StatusBadRequest, Message: err.Error()}) return } @@ -83,7 +84,7 @@ func (p *Plugin) handleLink(w http.ResponseWriter, r *http.Request) { var body *serializers.LinkRequestPayload decoder := json.NewDecoder(r.Body) if err := decoder.Decode(&body); err != nil { - p.API.LogError("Error in decoding body", "Error", err.Error()) + p.API.LogError(constants.ErrorDecodingBody, "Error", err.Error()) p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()}) return } @@ -131,21 +132,28 @@ func (p *Plugin) handleGetAllLinkedProjects(w http.ResponseWriter, r *http.Reque projectList, err := p.Store.GetAllProjects(mattermostUserID) if err != nil { p.API.LogError(constants.ErrorFetchProjectList, "Error", err.Error()) - error := serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()} - p.handleError(w, r, &error) + p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + return + } + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + if projectList == nil { + if _, err = w.Write([]byte("[]")); err != nil { + p.API.LogError(constants.ErrorFetchProjectList, "Error", err.Error()) + p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + } return } response, err := json.Marshal(projectList) if err != nil { p.API.LogError(constants.ErrorFetchProjectList, "Error", err.Error()) - error := serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()} - p.handleError(w, r, &error) + p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()}) return } - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) if _, err := w.Write(response); err != nil { p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()}) } @@ -156,8 +164,7 @@ func (p *Plugin) handleAuthRequired(handleFunc http.HandlerFunc) http.HandlerFun return func(w http.ResponseWriter, r *http.Request) { mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) if mattermostUserID == "" { - error := serializers.Error{Code: http.StatusUnauthorized, Message: constants.NotAuthorized} - p.handleError(w, r, &error) + p.handleError(w, r, &serializers.Error{Code: http.StatusUnauthorized, Message: constants.NotAuthorized}) return } @@ -178,6 +185,54 @@ func (p *Plugin) handleError(w http.ResponseWriter, r *http.Request, error *seri } } +// handleUnlinkProject unlinks a project +func (p *Plugin) handleUnlinkProject(w http.ResponseWriter, r *http.Request) { + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + + var project *serializers.ProjectDetails + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&project); err != nil { + p.API.LogError(constants.ErrorDecodingBody, "Error", err.Error()) + p.handleError(w, r, &serializers.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + projectList, err := p.Store.GetAllProjects(mattermostUserID) + if err != nil { + p.API.LogError(constants.ErrorFetchProjectList, "Error", err.Error()) + p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + return + } + + if !p.IsProjectLinked(projectList, *project) { + p.API.LogError(constants.ProjectNotFound, "Project", project.ProjectName) + p.handleError(w, r, &serializers.Error{Code: http.StatusNotFound, Message: constants.ProjectNotFound}) + return + } + + if err := p.Store.DeleteProject(project); err != nil { + p.API.LogError(constants.ErrorUnlinkProject, "Error", err.Error()) + p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + } + + successResponse := &serializers.SuccessResponse{ + Message: "success", + } + + response, err := json.Marshal(&successResponse) + if err != nil { + p.API.LogError("Error marshaling the response", "Error", err.Error()) + p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + return + } + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(response); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + func (p *Plugin) WithRecovery(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { diff --git a/server/serializers/error.go b/server/serializers/common.go similarity index 65% rename from server/serializers/error.go rename to server/serializers/common.go index e8e9c8ad..a648ca4c 100644 --- a/server/serializers/error.go +++ b/server/serializers/common.go @@ -5,3 +5,7 @@ type Error struct { Code int Message string } + +type SuccessResponse struct { + Message string `json:"message"` +} diff --git a/webapp/src/components/buttons/iconButton/index.tsx b/webapp/src/components/buttons/iconButton/index.tsx index 3d74a30e..f1272702 100644 --- a/webapp/src/components/buttons/iconButton/index.tsx +++ b/webapp/src/components/buttons/iconButton/index.tsx @@ -20,7 +20,8 @@ const IconButton = ({tooltipText, iconClassName, extraClass = '', iconColor, onC + ) + } + + +); + +export default EmptyState; diff --git a/webapp/src/containers/Rhs/tabs/no_data/styles.scss b/webapp/src/components/emptyState/styles.scss similarity index 54% rename from webapp/src/containers/Rhs/tabs/no_data/styles.scss rename to webapp/src/components/emptyState/styles.scss index c51f552d..c55d2588 100644 --- a/webapp/src/containers/Rhs/tabs/no_data/styles.scss +++ b/webapp/src/components/emptyState/styles.scss @@ -1,11 +1,12 @@ .no-data { - text-align: center; height: 100%; - min-height: 350px; + justify-content: center; + margin-top: 80px; + text-align: center; &__icon { - width: 120px; - height: 120px; + width: 100px; + height: 100px; background: rgba(var(--center-channel-color-rgb), 0.04); border-radius: 100%; margin-bottom: 24px; @@ -24,4 +25,14 @@ &__btn { margin-top: 24px; } + + svg { + fill: rgba(var(--center-channel-color-rgb), 0.5); + } +} + +.slash-command { + padding: 10px 15px; + border-radius: 4px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.08); } diff --git a/webapp/src/components/labelValuePair/index.tsx b/webapp/src/components/labelValuePair/index.tsx index ea859222..8c017ad9 100644 --- a/webapp/src/components/labelValuePair/index.tsx +++ b/webapp/src/components/labelValuePair/index.tsx @@ -10,7 +10,7 @@ type LabelValuePairProps = { const LabelValuePair = ({label, value}: LabelValuePairProps) => { return (

- {label}{': '} + {`${label}: `} {value}

); diff --git a/webapp/src/components/modal/confirmationModal/index.tsx b/webapp/src/components/modal/confirmationModal/index.tsx new file mode 100644 index 00000000..72078311 --- /dev/null +++ b/webapp/src/components/modal/confirmationModal/index.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import Modal from 'components/modal'; + +type ConfirmationModalProps = { + isOpen: boolean + title: string + description: string + confirmBtnText: string + onHide: () => void + onConfirm?: () => void + isLoading?: boolean +} + +const ConfirmationModal = ({isOpen, title, confirmBtnText, description, onHide, onConfirm}: ConfirmationModalProps) => ( + +

{description}

+
+); + +export default ConfirmationModal; diff --git a/webapp/src/components/modal/index.tsx b/webapp/src/components/modal/index.tsx index 320a5540..b37c3543 100644 --- a/webapp/src/components/modal/index.tsx +++ b/webapp/src/components/modal/index.tsx @@ -17,6 +17,7 @@ type ModalProps = { onConfirm?: () => void; confirmBtnText?: string; cancelBtnText?: string; + confirmAction?: boolean; className?: string; loading?: boolean; error?: string | JSX.Element; @@ -24,7 +25,7 @@ type ModalProps = { cancelDisabled?: boolean; } -const Modal = ({show, onHide, showCloseIconInHeader = true, children, title, subTitle, onConfirm, confirmBtnText, cancelBtnText, className = '', loading = false, error, confirmDisabled = false, cancelDisabled = false}: ModalProps) => { +const Modal = ({show, onHide, showCloseIconInHeader = true, children, title, subTitle, onConfirm, confirmAction, confirmBtnText, cancelBtnText, className = '', loading = false, error}: ModalProps) => { return ( ); diff --git a/webapp/src/components/modal/subComponents/modalFooter/index.tsx b/webapp/src/components/modal/subComponents/modalFooter/index.tsx index 88bfcb18..82c8eaba 100644 --- a/webapp/src/components/modal/subComponents/modalFooter/index.tsx +++ b/webapp/src/components/modal/subComponents/modalFooter/index.tsx @@ -11,13 +11,14 @@ type ModalFooterProps = { className?: string; confirmDisabled?: boolean; cancelDisabled?: boolean; + confirmAction?: boolean; } -const ModalFooter = ({onConfirm, onHide, cancelBtnText, confirmBtnText, className = '', confirmDisabled, cancelDisabled}: ModalFooterProps) : JSX.Element => ( - +const ModalFooter = ({onConfirm, onHide, cancelBtnText, confirmBtnText, className = '', confirmDisabled, cancelDisabled, confirmAction}: ModalFooterProps) : JSX.Element => ( + {onConfirm && ( - - - ); -}; - -export default NoData; diff --git a/webapp/src/containers/Rhs/tabs/styles.scss b/webapp/src/containers/Rhs/tabs/styles.scss deleted file mode 100644 index f3f3d746..00000000 --- a/webapp/src/containers/Rhs/tabs/styles.scss +++ /dev/null @@ -1,39 +0,0 @@ -.rhs-tabs { - // height = right-sidebar-height - right-sidebar-header-height(63px); - height: calc(100% - 63px); - - .tab-content { - // height = (right-sidebar-height - right-sidebar-header-height = 100%) - tabs-height; - height: calc(100% - 63px); - overflow: auto; - - .tab-pane { - height: 100%; - } - } - - &__cards-container { - padding-bottom: 50px; - } - - &__btn-container { - position: fixed; - left: 1px; - right: 0; - z-index: 1; - bottom: 0; - background: var(--center-channel-bg); - padding: 12px 12px 12px 11px; - box-shadow: 9px 2px 5px 5px var(--center-channel-bg); - } - - .tab-btn { - width: 100%; - } -} - -.rhs-modal { - .dropdown { - margin-bottom: 12px; - } -} diff --git a/webapp/src/containers/Rhs/tabs/subscriptions/index.tsx b/webapp/src/containers/Rhs/tabs/subscriptions/index.tsx deleted file mode 100644 index 9a5d7128..00000000 --- a/webapp/src/containers/Rhs/tabs/subscriptions/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ - -import React from 'react'; - -import NoData from '../no_data'; - -const Subscriptions = (): JSX.Element => { - return ( - ''} - /> - ); -}; - -export default Subscriptions; diff --git a/webapp/src/hooks/usePluginApi.ts b/webapp/src/hooks/usePluginApi.ts index 495aade3..c17d6dab 100644 --- a/webapp/src/hooks/usePluginApi.ts +++ b/webapp/src/hooks/usePluginApi.ts @@ -1,14 +1,15 @@ import {useSelector, useDispatch} from 'react-redux'; +import {AnyAction} from 'redux'; import services from 'services'; function usePluginApi() { - const state = useSelector((pluginState: PluginState) => pluginState); + const state = useSelector((pluginState: ReduxState) => pluginState); const dispatch = useDispatch(); // Pass payload only in POST rquests for GET requests there is no need to pass payload argument - const makeApiRequest = (serviceName: ApiServiceName, payload: APIRequestPayload) => { - dispatch(services.endpoints[serviceName].initiate(payload)); + const makeApiRequest = (serviceName: ApiServiceName, payload: APIRequestPayload): Promise => { + return dispatch(services.endpoints[serviceName].initiate(payload)); //TODO: add proper type here }; // Pass payload only in POST rquests for GET requests there is no need to pass payload argument diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index 5b845c82..bbe3fb45 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -28,7 +28,7 @@ export default class Plugin { registry.registerReducer(reducer); registry.registerRootComponent(TaskModal); registry.registerRootComponent(LinkModal); - const {showRHSPlugin} = registry.registerRightHandSidebarComponent(Rhs, Constants.RightSidebarHeader); + const {showRHSPlugin} = registry.registerRightHandSidebarComponent(App, Constants.RightSidebarHeader); const hooks = new Hooks(store); registry.registerSlashCommandWillBePostedHook(hooks.slashCommandWillBePostedHook); registry.registerChannelHeaderButtonAction(, () => store.dispatch(showRHSPlugin), null, Constants.AzureDevops); diff --git a/webapp/src/plugin_constants/index.ts b/webapp/src/plugin_constants/index.ts index 21e83d80..76c38c19 100644 --- a/webapp/src/plugin_constants/index.ts +++ b/webapp/src/plugin_constants/index.ts @@ -28,6 +28,16 @@ const pluginApiServiceConfigs: Record = { method: 'GET', apiServiceName: 'testGet', }, + getAllLinkedProjectsList: { + path: '/link/project', + method: 'GET', + apiServiceName: 'getAllLinkedProjectsList', + }, + unlinkProject: { + path: '/unlink/project', + method: 'POST', + apiServiceName: 'unlinkProject', + }, }; export default { diff --git a/webapp/src/reducers/linkModal/index.ts b/webapp/src/reducers/linkModal/index.ts index 539aa8ca..e0014cc6 100644 --- a/webapp/src/reducers/linkModal/index.ts +++ b/webapp/src/reducers/linkModal/index.ts @@ -1,24 +1,19 @@ -import {createSlice} from '@reduxjs/toolkit'; +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; import {getProjectLinkDetails} from 'utils'; -export interface CreateTaskModal { - visibility: boolean, - organization: string, - project: string, -} - -const initialState: CreateTaskModal = { +const initialState: LinkProjectModalState = { visibility: false, organization: '', project: '', + isLinked: false, }; export const openLinkModalSlice = createSlice({ name: 'openLinkModal', initialState, reducers: { - showLinkModal: (state, action) => { + showLinkModal: (state: LinkProjectModalState, action: PayloadAction>) => { if (action.payload.length > 2) { const details = getProjectLinkDetails(action.payload[2]); if (details.length === 2) { @@ -27,15 +22,19 @@ export const openLinkModalSlice = createSlice({ } } state.visibility = true; + state.isLinked = false; }, - hideLinkModal: (state) => { + hideLinkModal: (state: LinkProjectModalState) => { state.visibility = false; state.organization = ''; state.project = ''; }, + toggleIsLinked: (state: LinkProjectModalState, action: PayloadAction) => { + state.isLinked = action.payload; + }, }, }); -export const {showLinkModal, hideLinkModal} = openLinkModalSlice.actions; +export const {showLinkModal, hideLinkModal, toggleIsLinked} = openLinkModalSlice.actions; export default openLinkModalSlice.reducer; diff --git a/webapp/src/reducers/projectDetails/index.ts b/webapp/src/reducers/projectDetails/index.ts index 728bd859..742db1a3 100644 --- a/webapp/src/reducers/projectDetails/index.ts +++ b/webapp/src/reducers/projectDetails/index.ts @@ -1,9 +1,10 @@ import {createSlice, PayloadAction} from '@reduxjs/toolkit'; const initialState: ProjectDetails = { - id: '', - organization: '', - title: '', + mattermostUserID: '', + projectID: '', + projectName: '', + organizationName: '', }; export const projectDetailsSlice = createSlice({ @@ -11,14 +12,16 @@ export const projectDetailsSlice = createSlice({ initialState, reducers: { setProjectDetails: (state: ProjectDetails, action: PayloadAction) => { - state.id = action.payload.id; - state.title = action.payload.title; - state.organization = action.payload.organization; + state.mattermostUserID = action.payload.mattermostUserID; + state.projectID = action.payload.projectID; + state.projectName = action.payload.projectName; + state.organizationName = action.payload.organizationName; }, resetProjectDetails: (state: ProjectDetails) => { - state.id = ''; - state.title = ''; - state.organization = ''; + state.mattermostUserID = ''; + state.projectID = ''; + state.projectName = ''; + state.organizationName = ''; }, }, }); diff --git a/webapp/src/selectors/index.tsx b/webapp/src/selectors/index.tsx index 6ca79d19..05f8cb77 100644 --- a/webapp/src/selectors/index.tsx +++ b/webapp/src/selectors/index.tsx @@ -1,7 +1,11 @@ -import plugin_constants from 'plugin_constants'; +export const getprojectDetailsState = (state: ReduxState) => { + return state['plugins-mattermost-plugin-azure-devops'].projectDetailsSlice; +}; -const pluginPrefix = `plugins-${plugin_constants.pluginId}`; +export const getRhsState = (state: ReduxState): {isSidebarOpen: boolean} => { + return state.views.rhs; +}; -export const getprojectDetailsState = (state: any) => { - return state[pluginPrefix].projectDetailsSlice; +export const getLinkModalState = (state: ReduxState): LinkProjectModalState => { + return state['plugins-mattermost-plugin-azure-devops'].openLinkModalReducer; }; diff --git a/webapp/src/services/index.ts b/webapp/src/services/index.ts index 70c64ee5..f94f6a74 100644 --- a/webapp/src/services/index.ts +++ b/webapp/src/services/index.ts @@ -11,7 +11,7 @@ const pluginApi = createApi({ baseQuery: fetchBaseQuery({baseUrl: Utils.getBaseUrls().pluginApiBaseUrl}), tagTypes: ['Posts'], endpoints: (builder) => ({ - [Constants.pluginApiServiceConfigs.createTask.apiServiceName]: builder.query({ + [Constants.pluginApiServiceConfigs.createTask.apiServiceName]: builder.query({ query: (payload) => ({ headers: {[Constants.HeaderMattermostUserID]: Cookies.get(Constants.MMUSERID)}, url: Constants.pluginApiServiceConfigs.createTask.path, @@ -19,7 +19,7 @@ const pluginApi = createApi({ body: payload, }), }), - [Constants.pluginApiServiceConfigs.createLink.apiServiceName]: builder.query({ + [Constants.pluginApiServiceConfigs.createLink.apiServiceName]: builder.query({ query: (payload) => ({ headers: {[Constants.HeaderMattermostUserID]: Cookies.get(Constants.MMUSERID)}, url: Constants.pluginApiServiceConfigs.createLink.path, @@ -27,6 +27,21 @@ const pluginApi = createApi({ body: payload, }), }), + [Constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName]: builder.query({ + query: () => ({ + headers: {[Constants.HeaderMattermostUserID]: Cookies.get(Constants.MMUSERID)}, + url: Constants.pluginApiServiceConfigs.getAllLinkedProjectsList.path, + method: Constants.pluginApiServiceConfigs.getAllLinkedProjectsList.method, + }), + }), + [Constants.pluginApiServiceConfigs.unlinkProject.apiServiceName]: builder.query({ + query: (payload) => ({ + headers: {[Constants.HeaderMattermostUserID]: Cookies.get(Constants.MMUSERID)}, + url: Constants.pluginApiServiceConfigs.unlinkProject.path, + method: Constants.pluginApiServiceConfigs.unlinkProject.method, + body: payload, + }), + }), }), }); diff --git a/webapp/src/styles/_components.scss b/webapp/src/styles/_components.scss index 7339df9b..9d982fba 100644 --- a/webapp/src/styles/_components.scss +++ b/webapp/src/styles/_components.scss @@ -1,3 +1,7 @@ +.height-rhs { + height: 100%; +} + .project-details-unlink-button { max-width: 42px; height: 34px; @@ -7,6 +11,9 @@ } .unlink-button { + margin-left: auto; + display: block; + i { margin: 6px 5px 0 0; } diff --git a/webapp/src/styles/_global.scss b/webapp/src/styles/_global.scss index 76eec3e1..326ec34d 100644 --- a/webapp/src/styles/_global.scss +++ b/webapp/src/styles/_global.scss @@ -3,3 +3,11 @@ outline: none !important; } } + +.project-details { + flex-basis: 70%; +} + +.button-wrapper { + flex-basis: 30%; +} diff --git a/webapp/src/styles/_utils.scss b/webapp/src/styles/_utils.scss index 0a1a7b5c..57c0ca12 100644 --- a/webapp/src/styles/_utils.scss +++ b/webapp/src/styles/_utils.scss @@ -70,7 +70,7 @@ cursor: pointer; &:hover { - color: darken($link-color, 30); + color: darken($link-color, 20); text-decoration: underline; } } diff --git a/webapp/src/types/common/index.d.ts b/webapp/src/types/common/index.d.ts index 7c122984..bed8557d 100644 --- a/webapp/src/types/common/index.d.ts +++ b/webapp/src/types/common/index.d.ts @@ -4,15 +4,20 @@ type HttpMethod = 'GET' | 'POST'; -type ApiServiceName = 'createTask' | 'testGet' | 'createLink' +type ApiServiceName = 'createTask' | 'testGet' | 'createLink' | 'getAllLinkedProjectsList' | 'unlinkProject' type PluginApiService = { path: string, - method: httpMethod, + method: HttpMethod, apiServiceName: ApiServiceName } -type PluginState = { +interface ReduxState extends GlobalState { + views: { + rhs: { + isSidebarOpen: boolean + } + } 'plugins-mattermost-plugin-azure-devops': RootState<{ [x: string]: QueryDefinition, never, WellList[], 'pluginApi'>; }, never, 'pluginApi'> } @@ -38,7 +43,7 @@ type CreateTaskPayload = { fields: CreateTaskFields, } -type APIRequestPayload = CreateTaskPayload | LinkPayload | void; +type APIRequestPayload = CreateTaskPayload | LinkPayload | ProjectDetails | void; type DropdownOptionType = { label?: string | JSX.Element; @@ -51,9 +56,10 @@ type TabsData = { } type ProjectDetails = { - id: string - title: string - organization: string + mattermostUserID: string + projectID: string, + projectName: string, + organizationName: string } type eventType = 'create' | 'update' | 'delete' diff --git a/webapp/src/types/common/store.d.ts b/webapp/src/types/common/store.d.ts new file mode 100644 index 00000000..db390532 --- /dev/null +++ b/webapp/src/types/common/store.d.ts @@ -0,0 +1,6 @@ +type LinkProjectModalState = { + visibility: boolean, + organization: string, + project: string, + isLinked: boolean, +} diff --git a/webapp/src/utils/index.ts b/webapp/src/utils/index.ts index 987686e9..4271bf39 100644 --- a/webapp/src/utils/index.ts +++ b/webapp/src/utils/index.ts @@ -37,7 +37,7 @@ export const getProjectLinkDetails = (str: string) => { } const values = [data[3], data[4]]; return values; -} +}; export const onPressingEnterKey = (event: Event | undefined, func: () => void) => { if (event instanceof KeyboardEvent && event.key !== 'Enter' && event.key !== ' ') { From fc74ee8dcaf0d2974fe79f526932e99b10bf0c3a Mon Sep 17 00:00:00 2001 From: Abhishek Verma <72438220+avas27JTG@users.noreply.github.com> Date: Wed, 31 Aug 2022 14:59:50 +0530 Subject: [PATCH 2/8] =?UTF-8?q?[MI-2002]:=20Created=20plugin=20API=20to=20?= =?UTF-8?q?fetch=20user=20details=20and=20UI=20integratio=E2=80=A6=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [MI-1986]: Create plugin API to fetch linked projects list * [MI-1987]: Integrated project list UI * [MI-1987]: Review fixes * [MI-2001]: [MI-2001]: Create plugin API to unlink project and integrate the UI * [MI-2001]: Review fixes * [MI-2002]: Created plugin API to fetch user details and UI integration with other changes * [MI-2002]: Review fixes * [MI-2002]: Updated API paths * [MI-1987]: Review fix * [MI-1987]: Review fixes * [MI-2001]: Review fixes * [MI-2001]: Review fixes * [MI-2002]: Review fixes * [MI-2002]: Review fix * [MI-2002]: Review fixes Co-authored-by: Abhishek Verma --- server/constants/messages.go | 1 + server/constants/routes.go | 5 ++- server/plugin/api.go | 31 +++++++++++++++ webapp/src/components/emptyState/index.tsx | 3 ++ webapp/src/components/emptyState/styles.scss | 6 +++ .../containers/Rhs/accountNotLinked/index.tsx | 38 +++++++++++++++++++ webapp/src/containers/Rhs/index.tsx | 34 +++++++++++++---- .../src/containers/Rhs/projectList/index.tsx | 13 ++----- webapp/src/hooks/index.js | 22 ++++++++++- webapp/src/plugin_constants/index.ts | 9 ++++- webapp/src/services/index.ts | 7 ++++ webapp/src/styles/_components.scss | 15 ++++++++ webapp/src/styles/_utils.scss | 5 +++ webapp/src/types/common/index.d.ts | 8 +++- 14 files changed, 174 insertions(+), 23 deletions(-) create mode 100644 webapp/src/containers/Rhs/accountNotLinked/index.tsx diff --git a/server/constants/messages.go b/server/constants/messages.go index 9afcf2cf..3d9653c2 100644 --- a/server/constants/messages.go +++ b/server/constants/messages.go @@ -26,6 +26,7 @@ const ( GetProjectListError = "Error getting Project List" ErrorFetchProjectList = "Error in fetching project list" ErrorDecodingBody = "Error in decoding body" + ErrorLoadingDataFromKVStore = "Error in loading data from KV store" ProjectNotFound = "Requested project does not exist" ErrorUnlinkProject = "Error in unlinking the project" ) diff --git a/server/constants/routes.go b/server/constants/routes.go index 74ff53cb..fbe21f1f 100644 --- a/server/constants/routes.go +++ b/server/constants/routes.go @@ -6,6 +6,7 @@ const ( WildRoute = "{anything:.*}" PathOAuthConnect = "/oauth/connect" PathOAuthCallback = "/oauth/complete" - PathLinkedProjects = "/link/project" - PathUnlinkProject = "/unlink/project" + PathLinkedProjects = "/project/link" + PathUnlinkProject = "/project/unlink" + PathUser = "/user" ) diff --git a/server/plugin/api.go b/server/plugin/api.go index 4d041000..cc1b33e4 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -38,6 +38,7 @@ func (p *Plugin) InitRoutes() { s.HandleFunc("/link", p.handleAuthRequired(p.handleLink)).Methods(http.MethodPost) s.HandleFunc(constants.PathLinkedProjects, p.handleAuthRequired(p.handleGetAllLinkedProjects)).Methods(http.MethodGet) s.HandleFunc(constants.PathUnlinkProject, p.handleAuthRequired(p.handleUnlinkProject)).Methods(http.MethodPost) + s.HandleFunc(constants.PathUser, p.handleAuthRequired(p.handleGetUserAccountDetails)).Methods(http.MethodGet) } // API to create task of a project in an organization. @@ -233,6 +234,36 @@ func (p *Plugin) handleUnlinkProject(w http.ResponseWriter, r *http.Request) { } } +// handleGetUserAccountDetails provides user details +func (p *Plugin) handleGetUserAccountDetails(w http.ResponseWriter, r *http.Request) { + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + userDetails, err := p.Store.LoadUser(mattermostUserID) + if err != nil { + p.API.LogError(constants.ErrorLoadingDataFromKVStore, "Error", err.Error()) + p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + return + } + + if userDetails.MattermostUserID == "" { + p.API.LogError(constants.ConnectAccountFirst, "Error") + p.handleError(w, r, &serializers.Error{Code: http.StatusUnauthorized, Message: constants.ConnectAccountFirst}) + return + } + + response, err := json.Marshal(&userDetails) + if err != nil { + p.API.LogError("Error marshaling the response", "Error", err.Error()) + p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + return + } + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(response); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + func (p *Plugin) WithRecovery(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { diff --git a/webapp/src/components/emptyState/index.tsx b/webapp/src/components/emptyState/index.tsx index b859b91a..15a3c9df 100644 --- a/webapp/src/components/emptyState/index.tsx +++ b/webapp/src/components/emptyState/index.tsx @@ -2,6 +2,8 @@ import React from 'react'; import './styles.scss'; +type DisplayIcon = 'folder' | 'azure' + type EmptyStatePropTypes = { title: string, subTitle?: { @@ -10,6 +12,7 @@ type EmptyStatePropTypes = { }, buttonText?: string, buttonAction?: (event: React.SyntheticEvent) => void; + icon?: DisplayIcon; } const EmptyState = ({title, subTitle, buttonText, buttonAction}: EmptyStatePropTypes) => ( diff --git a/webapp/src/components/emptyState/styles.scss b/webapp/src/components/emptyState/styles.scss index c55d2588..c3e06735 100644 --- a/webapp/src/components/emptyState/styles.scss +++ b/webapp/src/components/emptyState/styles.scss @@ -36,3 +36,9 @@ border-radius: 4px; border: 1px solid rgba(var(--center-channel-color-rgb), 0.08); } + +.slash-command { + padding: 10px 15px; + border-radius: 4px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.08); +} diff --git a/webapp/src/containers/Rhs/accountNotLinked/index.tsx b/webapp/src/containers/Rhs/accountNotLinked/index.tsx new file mode 100644 index 00000000..cd33d438 --- /dev/null +++ b/webapp/src/containers/Rhs/accountNotLinked/index.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import {useDispatch} from 'react-redux'; + +import EmptyState from 'components/emptyState'; + +import Utils from 'utils'; + +const AccountNotLinked = () => { + const dispatch = useDispatch(); + + const closeRHS = () => { + dispatch({ + type: 'UPDATE_RHS_STATE', + state: null, + }); + }; + + // Opens link project modal + const handleConnectAccount = () => { + window.open(`${Utils.getBaseUrls().pluginApiBaseUrl}/oauth/connect`, '_blank'); + closeRHS(); + }; + + return ( + <> + + + ); +}; + +export default AccountNotLinked; diff --git a/webapp/src/containers/Rhs/index.tsx b/webapp/src/containers/Rhs/index.tsx index 03a1fa98..f76a1c02 100644 --- a/webapp/src/containers/Rhs/index.tsx +++ b/webapp/src/containers/Rhs/index.tsx @@ -1,24 +1,44 @@ -import React from 'react'; +import React, {useEffect} from 'react'; import usePluginApi from 'hooks/usePluginApi'; import {getprojectDetailsState, getRhsState} from 'selectors'; +import LinearLoader from 'components/loader/linear'; + +import plugin_constants from 'plugin_constants'; + +import AccountNotLinked from './accountNotLinked'; import ProjectList from './projectList'; import ProjectDetails from './projectDetails'; const Rhs = (): JSX.Element => { - const usePlugin = usePluginApi(); + const {getApiState, makeApiRequest, state} = usePluginApi(); + + // Fetch the connected account details when RHS is opened + useEffect(() => { + if (getRhsState(state).isSidebarOpen) { + makeApiRequest(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName); + } + }, []); + + const {isLoading, isError, isSuccess} = getApiState(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName); - if (!getRhsState(usePlugin.state).isSidebarOpen) { + if (!getRhsState(state).isSidebarOpen) { return <>; } return ( -
+
+ {isLoading && } + {isError && } { - getprojectDetailsState(usePlugin.state).projectID ? - : - + !isLoading && + !isError && + isSuccess && ( + getprojectDetailsState(state).projectID ? + : + + ) }
); diff --git a/webapp/src/containers/Rhs/projectList/index.tsx b/webapp/src/containers/Rhs/projectList/index.tsx index c9d2e872..91c662a6 100644 --- a/webapp/src/containers/Rhs/projectList/index.tsx +++ b/webapp/src/containers/Rhs/projectList/index.tsx @@ -66,7 +66,7 @@ const ProjectList = () => { } }, [getLinkModalState(usePlugin.state)]); - const {data} = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName); + const {data, isSuccess, isLoading} = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName); return ( <> @@ -76,20 +76,15 @@ const ProjectList = () => { isOpen={showConfirmationModal} onHide={() => setShowConfirmationModal(false)} onConfirm={handleConfirmUnlinkProject} - isLoading={usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.unlinkProject.apiServiceName, projectToBeUnlinked).isLoading} + isLoading={isLoading} confirmBtnText='Unlink' description={`Are you sure you want to unlink ${projectToBeUnlinked?.projectName}?`} title='Confirm Project Unlink' /> } + {isLoading && } { - usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName).isLoading && ( - - ) - } - { - usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName).isSuccess && - data && + isSuccess && data && ( data.length ? data.map((item) => ( diff --git a/webapp/src/hooks/index.js b/webapp/src/hooks/index.js index 955cd39a..8b9dd8c8 100644 --- a/webapp/src/hooks/index.js +++ b/webapp/src/hooks/index.js @@ -7,6 +7,13 @@ export default class Hooks { this.store = store; } + closeRhs() { + this.store.dispatch({ + type: 'UPDATE_RHS_STATE', + state: null, + }); + } + slashCommandWillBePostedHook = (message, contextArgs) => { let commandTrimmed; if (message) { @@ -19,7 +26,6 @@ export default class Hooks { args: contextArgs, }); } - if (commandTrimmed && commandTrimmed.startsWith('/azuredevops boards create')) { const args = splitArgs(commandTrimmed); this.store.dispatch(showTaskModal(args)); @@ -30,6 +36,20 @@ export default class Hooks { this.store.dispatch(showLinkModal(args)); return Promise.resolve({}); } + if (commandTrimmed && commandTrimmed.startsWith('/azuredevops connect')) { + this.closeRhs(); + return { + message, + args: contextArgs, + }; + } + if (commandTrimmed && commandTrimmed.startsWith('/azuredevops disconnect')) { + this.closeRhs(); + return { + message, + args: contextArgs, + }; + } return Promise.resolve({ message, args: contextArgs, diff --git a/webapp/src/plugin_constants/index.ts b/webapp/src/plugin_constants/index.ts index 76c38c19..6bceb421 100644 --- a/webapp/src/plugin_constants/index.ts +++ b/webapp/src/plugin_constants/index.ts @@ -29,15 +29,20 @@ const pluginApiServiceConfigs: Record = { apiServiceName: 'testGet', }, getAllLinkedProjectsList: { - path: '/link/project', + path: '/project/link', method: 'GET', apiServiceName: 'getAllLinkedProjectsList', }, unlinkProject: { - path: '/unlink/project', + path: '/project/unlink', method: 'POST', apiServiceName: 'unlinkProject', }, + getUserDetails: { + path: '/user', + method: 'GET', + apiServiceName: 'getUserDetails', + }, }; export default { diff --git a/webapp/src/services/index.ts b/webapp/src/services/index.ts index f94f6a74..8456a77d 100644 --- a/webapp/src/services/index.ts +++ b/webapp/src/services/index.ts @@ -42,6 +42,13 @@ const pluginApi = createApi({ body: payload, }), }), + [Constants.pluginApiServiceConfigs.getUserDetails.apiServiceName]: builder.query({ + query: () => ({ + headers: {[Constants.HeaderMattermostUserID]: Cookies.get(Constants.MMUSERID)}, + url: Constants.pluginApiServiceConfigs.getUserDetails.path, + method: Constants.pluginApiServiceConfigs.getUserDetails.method, + }), + }), }), }); diff --git a/webapp/src/styles/_components.scss b/webapp/src/styles/_components.scss index 9d982fba..89c503bb 100644 --- a/webapp/src/styles/_components.scss +++ b/webapp/src/styles/_components.scss @@ -18,3 +18,18 @@ margin: 6px 5px 0 0; } } + +.rhs-project-list-wrapper { + position: fixed; + bottom: 0; + left: 0; + padding: 15px 0 30px; + width: 100%; + background-color: var(--center-channel-bg); + box-shadow: 5px -1px 5px rgba(0, 0, 0, 0.1); + text-align: center; + + .project-list-btn { + width: calc(100% - 60px); // 60px is the width of padding from left and right + } +} diff --git a/webapp/src/styles/_utils.scss b/webapp/src/styles/_utils.scss index 57c0ca12..e6bef30b 100644 --- a/webapp/src/styles/_utils.scss +++ b/webapp/src/styles/_utils.scss @@ -84,3 +84,8 @@ .cursor-pointer { cursor: pointer; } + +// Overflows +.overflow-auto { + overflow: auto; +} diff --git a/webapp/src/types/common/index.d.ts b/webapp/src/types/common/index.d.ts index bed8557d..09a22791 100644 --- a/webapp/src/types/common/index.d.ts +++ b/webapp/src/types/common/index.d.ts @@ -4,7 +4,7 @@ type HttpMethod = 'GET' | 'POST'; -type ApiServiceName = 'createTask' | 'testGet' | 'createLink' | 'getAllLinkedProjectsList' | 'unlinkProject' +type ApiServiceName = 'createTask' | 'testGet' | 'createLink' | 'getAllLinkedProjectsList' | 'unlinkProject' | 'getUserDetails' type PluginApiService = { path: string, @@ -43,7 +43,7 @@ type CreateTaskPayload = { fields: CreateTaskFields, } -type APIRequestPayload = CreateTaskPayload | LinkPayload | ProjectDetails | void; +type APIRequestPayload = CreateTaskPayload | LinkPayload | ProjectDetails | UserDetails | void; type DropdownOptionType = { label?: string | JSX.Element; @@ -62,6 +62,10 @@ type ProjectDetails = { organizationName: string } +type UserDetails = { + MattermostUserID: string +} + type eventType = 'create' | 'update' | 'delete' type SubscriptionDetails = { From c87e47b6e68e9beb371cb2eda38db056407efecb Mon Sep 17 00:00:00 2001 From: Abhishek Verma <72438220+avas27JTG@users.noreply.github.com> Date: Thu, 1 Sep 2022 15:44:06 +0530 Subject: [PATCH 3/8] =?UTF-8?q?[MI-2049]:=20Added=20websocket=20support=20?= =?UTF-8?q?to=20detect=20user=20connection=20details=20=E2=80=A6=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [MI-1986]: Create plugin API to fetch linked projects list * [MI-1987]: Integrated project list UI * [MI-1987]: Review fixes * [MI-2001]: [MI-2001]: Create plugin API to unlink project and integrate the UI * [MI-2001]: Review fixes * [MI-2002]: Created plugin API to fetch user details and UI integration with other changes * [MI-2002]: Review fixes * [MI-2002]: Updated API paths * [MI-2049]: Added websocket support to detect user connection details and a centralised check for root modals * [MI-1987]: Review fix * [MI-1987]: Review fixes * [MI-2001]: Review fixes * [MI-2001]: Review fixes * [MI-2002]: Review fixes * [MI-2049]: Review fixes * [MI-2049]: Fixed merge change * [MI-2049]: Refactored code * [MI-2049]: Review fix * [MI-2049]: Review fix Co-authored-by: Abhishek Verma --- server/constants/constants.go | 4 ++ server/plugin/command.go | 15 +++++++ server/plugin/oAuth.go | 6 +++ server/serializers/oAuth.go | 4 ++ server/store/user.go | 6 +-- webapp/src/app.tsx | 44 ++++++++++++++++--- webapp/src/components/emptyState/styles.scss | 10 ----- webapp/src/containers/LinkModal/index.tsx | 10 +++-- .../containers/Rhs/accountNotLinked/index.tsx | 12 ----- webapp/src/containers/Rhs/index.tsx | 35 ++++----------- .../src/containers/Rhs/projectList/index.tsx | 6 +-- webapp/src/containers/TaskModal/index.tsx | 3 +- webapp/src/hooks/index.js | 32 +++----------- webapp/src/hooks/usePluginApi.ts | 6 ++- webapp/src/index.tsx | 16 ++++--- webapp/src/reducers/globalModal/index.ts | 25 +++++++++++ webapp/src/reducers/index.ts | 12 +++-- webapp/src/reducers/linkModal/index.ts | 26 +++++------ webapp/src/reducers/taskModal/index.ts | 6 +-- webapp/src/reducers/userConnected/index.ts | 23 ++++++++++ webapp/src/selectors/index.tsx | 10 ++++- webapp/src/types/common/index.d.ts | 16 +++---- webapp/src/types/common/store.d.ts | 23 ++++++++++ webapp/src/types/mattermost-webapp/index.d.ts | 1 + webapp/src/utils/index.ts | 23 +++++----- webapp/src/websocket/index.ts | 16 +++++++ 26 files changed, 248 insertions(+), 142 deletions(-) create mode 100644 webapp/src/reducers/globalModal/index.ts create mode 100644 webapp/src/reducers/userConnected/index.ts create mode 100644 webapp/src/websocket/index.ts diff --git a/server/constants/constants.go b/server/constants/constants.go index 43757e19..ab67b3f6 100644 --- a/server/constants/constants.go +++ b/server/constants/constants.go @@ -45,4 +45,8 @@ const ( PageQueryParam = "$top" APIVersionQueryParam = "api-version" IDsQueryParam = "ids" + + // Websocket events + WSEventConnect = "connect" + WSEventDisconnect = "disconnect" ) diff --git a/server/plugin/command.go b/server/plugin/command.go index 07a28bde..faf5eef5 100644 --- a/server/plugin/command.go +++ b/server/plugin/command.go @@ -25,6 +25,7 @@ var azureDevopsCommandHandler = Handler{ "help": azureDevopsHelpCommand, "connect": azureDevopsConnectCommand, "disconnect": azureDevopsDisconnectCommand, + "link": azureDevopsAccountConnectionCheck, }, defaultHandler: executeDefault, } @@ -77,6 +78,14 @@ func (p *Plugin) getCommand() (*model.Command, error) { }, nil } +func azureDevopsAccountConnectionCheck(p *Plugin, c *plugin.Context, commandArgs *model.CommandArgs, args ...string) (*model.CommandResponse, *model.AppError) { + if isConnected := p.UserAlreadyConnected(commandArgs.UserId); !isConnected { + return p.sendEphemeralPostForCommand(commandArgs, constants.ConnectAccountFirst) + } + + return &model.CommandResponse{}, nil +} + func azureDevopsHelpCommand(p *Plugin, c *plugin.Context, commandArgs *model.CommandArgs, args ...string) (*model.CommandResponse, *model.AppError) { return p.sendEphemeralPostForCommand(commandArgs, constants.HelpText) } @@ -100,6 +109,12 @@ func azureDevopsDisconnectCommand(p *Plugin, c *plugin.Context, commandArgs *mod } message = constants.GenericErrorMessage } + + p.API.PublishWebSocketEvent( + constants.WSEventDisconnect, + nil, + &model.WebsocketBroadcast{UserId: commandArgs.UserId}, + ) } return p.sendEphemeralPostForCommand(commandArgs, message) } diff --git a/server/plugin/oAuth.go b/server/plugin/oAuth.go index 4fdfa1df..df287538 100644 --- a/server/plugin/oAuth.go +++ b/server/plugin/oAuth.go @@ -164,6 +164,12 @@ func (p *Plugin) GenerateOAuthToken(code, state string) error { return err } + p.API.PublishWebSocketEvent( + constants.WSEventConnect, + nil, + &model.WebsocketBroadcast{UserId: mattermostUserID}, + ) + return nil } diff --git a/server/serializers/oAuth.go b/server/serializers/oAuth.go index 2d9c837f..36deee84 100644 --- a/server/serializers/oAuth.go +++ b/server/serializers/oAuth.go @@ -18,3 +18,7 @@ type OAuthSuccessResponse struct { RefreshToken string `json:"refresh_token"` ExpiresIn string `json:"expires_in"` } + +type ConnectedResponse struct { + IsConnected bool `json:"connected"` +} diff --git a/server/store/user.go b/server/store/user.go index 263518ae..a44f5be5 100644 --- a/server/store/user.go +++ b/server/store/user.go @@ -8,7 +8,7 @@ type User struct { } func (s *Store) StoreUser(user *User) error { - if err := s.StoreJSON(user.MattermostUserID, user); err != nil { + if err := s.StoreJSON(GetOAuthKey(user.MattermostUserID), user); err != nil { return err } @@ -17,14 +17,14 @@ func (s *Store) StoreUser(user *User) error { func (s *Store) LoadUser(mattermostUserID string) (*User, error) { user := User{} - if err := s.LoadJSON(mattermostUserID, &user); err != nil { + if err := s.LoadJSON(GetOAuthKey(mattermostUserID), &user); err != nil { return nil, err } return &user, nil } func (s *Store) DeleteUser(mattermostUserID string) (bool, error) { - if err := s.Delete(mattermostUserID); err != nil { + if err := s.Delete(GetOAuthKey(mattermostUserID)); err != nil { return false, err } diff --git a/webapp/src/app.tsx b/webapp/src/app.tsx index 09523a94..10e6b715 100644 --- a/webapp/src/app.tsx +++ b/webapp/src/app.tsx @@ -1,14 +1,48 @@ -import React from 'react'; +import React, {useEffect} from 'react'; +import {useDispatch} from 'react-redux'; -import Rhs from 'containers/Rhs'; +import usePluginApi from 'hooks/usePluginApi'; + +import {getGlobalModalState, getLinkModalState} from 'selectors'; + +import {toggleShowLinkModal} from 'reducers/linkModal'; +import {resetGlobalModalState} from 'reducers/globalModal'; // Global styles import 'styles/main.scss'; /** - * Mattermost plugin allows registering only one component in RHS - * So, we would be grouping all the different components inside "Rhs" component to generate one final component for registration + * This is a central component for adding account connection validation on all the modals registered in the root component */ -const App = (): JSX.Element => ; +const App = (): JSX.Element => { + const usePlugin = usePluginApi(); + const dispatch = useDispatch(); + + /** + * When a command is issued on the Mattermost to open any modal + * then here we first check if the user's account is connected or not + * If the account is connected, we dispatch the action to open the required modal + * otherwise we reset the action and don't open any modal + */ + useEffect(() => { + const {modalId, commandArgs} = getGlobalModalState(usePlugin.state); + + if (usePlugin.isUserAccountConnected() && modalId) { + switch (modalId) { + case 'linkProject': + dispatch(toggleShowLinkModal({isVisible: true, commandArgs})); + break; + } + } else { + dispatch(resetGlobalModalState()); + } + }, [getGlobalModalState(usePlugin.state).modalId]); + + useEffect(() => { + dispatch(resetGlobalModalState()); + }, [getLinkModalState(usePlugin.state).visibility]); + + return <>; +}; export default App; diff --git a/webapp/src/components/emptyState/styles.scss b/webapp/src/components/emptyState/styles.scss index c3e06735..54993b75 100644 --- a/webapp/src/components/emptyState/styles.scss +++ b/webapp/src/components/emptyState/styles.scss @@ -25,16 +25,6 @@ &__btn { margin-top: 24px; } - - svg { - fill: rgba(var(--center-channel-color-rgb), 0.5); - } -} - -.slash-command { - padding: 10px 15px; - border-radius: 4px; - border: 1px solid rgba(var(--center-channel-color-rgb), 0.08); } .slash-command { diff --git a/webapp/src/containers/LinkModal/index.tsx b/webapp/src/containers/LinkModal/index.tsx index 7bf5dd09..c12d5ba0 100644 --- a/webapp/src/containers/LinkModal/index.tsx +++ b/webapp/src/containers/LinkModal/index.tsx @@ -5,7 +5,7 @@ import Input from 'components/inputField'; import Modal from 'components/modal'; import usePluginApi from 'hooks/usePluginApi'; -import {hideLinkModal, toggleIsLinked} from 'reducers/linkModal'; +import {toggleShowLinkModal, toggleIsLinked} from 'reducers/linkModal'; import {getLinkModalState} from 'selectors'; import plugin_constants from 'plugin_constants'; @@ -35,7 +35,7 @@ const LinkModal = () => { organization: '', project: '', }); - dispatch(hideLinkModal()); + dispatch(toggleShowLinkModal({isVisible: false, commandArgs: []})); }; // Set organization name @@ -82,14 +82,16 @@ const LinkModal = () => { } }; + // Set modal field values useEffect(() => { setProjectDetails({ organization: getLinkModalState(usePlugin.state).organization, project: getLinkModalState(usePlugin.state).project, }); - }, [getLinkModalState(usePlugin.state)]); + }, [getLinkModalState(usePlugin.state).visibility]); const {isLoading} = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.createLink.apiServiceName, projectDetails); + return ( { { - const dispatch = useDispatch(); - - const closeRHS = () => { - dispatch({ - type: 'UPDATE_RHS_STATE', - state: null, - }); - }; - // Opens link project modal const handleConnectAccount = () => { window.open(`${Utils.getBaseUrls().pluginApiBaseUrl}/oauth/connect`, '_blank'); - closeRHS(); }; return ( diff --git a/webapp/src/containers/Rhs/index.tsx b/webapp/src/containers/Rhs/index.tsx index f76a1c02..841a60d3 100644 --- a/webapp/src/containers/Rhs/index.tsx +++ b/webapp/src/containers/Rhs/index.tsx @@ -1,44 +1,25 @@ -import React, {useEffect} from 'react'; +import React from 'react'; import usePluginApi from 'hooks/usePluginApi'; -import {getprojectDetailsState, getRhsState} from 'selectors'; - -import LinearLoader from 'components/loader/linear'; - -import plugin_constants from 'plugin_constants'; +import {getprojectDetailsState} from 'selectors'; import AccountNotLinked from './accountNotLinked'; import ProjectList from './projectList'; import ProjectDetails from './projectDetails'; const Rhs = (): JSX.Element => { - const {getApiState, makeApiRequest, state} = usePluginApi(); - - // Fetch the connected account details when RHS is opened - useEffect(() => { - if (getRhsState(state).isSidebarOpen) { - makeApiRequest(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName); - } - }, []); - - const {isLoading, isError, isSuccess} = getApiState(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName); - - if (!getRhsState(state).isSidebarOpen) { - return <>; - } + const {isUserAccountConnected, state} = usePluginApi(); return (
- {isLoading && } - {isError && } { - !isLoading && - !isError && - isSuccess && ( + !isUserAccountConnected() && + } + { + isUserAccountConnected() && ( getprojectDetailsState(state).projectID ? : - - ) + ) }
); diff --git a/webapp/src/containers/Rhs/projectList/index.tsx b/webapp/src/containers/Rhs/projectList/index.tsx index 91c662a6..afe8435b 100644 --- a/webapp/src/containers/Rhs/projectList/index.tsx +++ b/webapp/src/containers/Rhs/projectList/index.tsx @@ -7,7 +7,7 @@ import LinearLoader from 'components/loader/linear'; import ConfirmationModal from 'components/modal/confirmationModal'; import {setProjectDetails} from 'reducers/projectDetails'; -import {showLinkModal, toggleIsLinked} from 'reducers/linkModal'; +import {toggleShowLinkModal, toggleIsLinked} from 'reducers/linkModal'; import {getLinkModalState} from 'selectors'; import usePluginApi from 'hooks/usePluginApi'; import plugin_constants from 'plugin_constants'; @@ -31,7 +31,7 @@ const ProjectList = () => { // Opens link project modal const handleOpenLinkProjectModal = () => { - dispatch(showLinkModal([])); + dispatch(toggleShowLinkModal({isVisible: true, commandArgs: []})); }; /** @@ -64,7 +64,7 @@ const ProjectList = () => { dispatch(toggleIsLinked(false)); fetchLinkedProjectsList(); } - }, [getLinkModalState(usePlugin.state)]); + }, [getLinkModalState(usePlugin.state).isLinked]); const {data, isSuccess, isLoading} = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName); diff --git a/webapp/src/containers/TaskModal/index.tsx b/webapp/src/containers/TaskModal/index.tsx index 440bd271..42f3438b 100644 --- a/webapp/src/containers/TaskModal/index.tsx +++ b/webapp/src/containers/TaskModal/index.tsx @@ -8,6 +8,7 @@ import Modal from 'components/modal'; import Constants from 'plugin_constants'; import usePluginApi from 'hooks/usePluginApi'; import {hideTaskModal} from 'reducers/taskModal'; +import {getTaskModalState} from 'selectors'; // TODO: fetch the organization and project options from API later. const organizationOptions = [ @@ -69,7 +70,7 @@ const TaskModal = () => { const [taskTitleError, setTaskTitleError] = useState(''); const [taskPayload, setTaskPayload] = useState(); const usePlugin = usePluginApi(); - const {visibility} = usePlugin.state['plugins-mattermost-plugin-azure-devops'].openTaskModalReducer; + const {visibility} = getTaskModalState(usePlugin.state); const dispatch = useDispatch(); useEffect(() => { diff --git a/webapp/src/hooks/index.js b/webapp/src/hooks/index.js index 8b9dd8c8..fc344fdd 100644 --- a/webapp/src/hooks/index.js +++ b/webapp/src/hooks/index.js @@ -1,6 +1,5 @@ -import {showLinkModal} from 'reducers/linkModal'; -import {showTaskModal} from 'reducers/taskModal'; -import {splitArgs} from '../utils'; +import {setGlobalModalState} from 'reducers/globalModal'; +import {getCommandArgs} from 'utils'; export default class Hooks { constructor(store) { @@ -20,36 +19,19 @@ export default class Hooks { commandTrimmed = message.trim(); } - if (!commandTrimmed?.startsWith('/azuredevops')) { + if (commandTrimmed && commandTrimmed.startsWith('/azuredevops link')) { + const commandArgs = getCommandArgs(commandTrimmed); + this.store.dispatch(setGlobalModalState({modalId: 'linkProject', commandArgs})); return Promise.resolve({ message, args: contextArgs, }); } if (commandTrimmed && commandTrimmed.startsWith('/azuredevops boards create')) { - const args = splitArgs(commandTrimmed); - this.store.dispatch(showTaskModal(args)); + // TODO: refactor + // const args = splitArgs(commandTrimmed); return Promise.resolve({}); } - if (commandTrimmed && commandTrimmed.startsWith('/azuredevops link')) { - const args = splitArgs(commandTrimmed); - this.store.dispatch(showLinkModal(args)); - return Promise.resolve({}); - } - if (commandTrimmed && commandTrimmed.startsWith('/azuredevops connect')) { - this.closeRhs(); - return { - message, - args: contextArgs, - }; - } - if (commandTrimmed && commandTrimmed.startsWith('/azuredevops disconnect')) { - this.closeRhs(); - return { - message, - args: contextArgs, - }; - } return Promise.resolve({ message, args: contextArgs, diff --git a/webapp/src/hooks/usePluginApi.ts b/webapp/src/hooks/usePluginApi.ts index c17d6dab..e6715427 100644 --- a/webapp/src/hooks/usePluginApi.ts +++ b/webapp/src/hooks/usePluginApi.ts @@ -18,7 +18,11 @@ function usePluginApi() { return {data, isError, isLoading, isSuccess}; }; - return {makeApiRequest, getApiState, state}; + const isUserAccountConnected = (): boolean => { + return state['plugins-mattermost-plugin-azure-devops'].userConnectedSlice.isConnected; + }; + + return {makeApiRequest, getApiState, state, isUserAccountConnected}; } export default usePluginApi; diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index bbe3fb45..0f00670c 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -5,30 +5,32 @@ import {GlobalState} from 'mattermost-redux/types/store'; import reducer from 'reducers'; -import Rhs from 'containers/Rhs'; +import {handleConnect, handleDisconnect} from 'websocket'; + import {ChannelHeaderBtn} from 'containers/action_buttons'; import Constants from 'plugin_constants'; import Hooks from 'hooks'; +import Rhs from 'containers/Rhs'; import LinkModal from 'containers/LinkModal'; import TaskModal from 'containers/TaskModal'; -import manifest from './manifest'; - -import App from './app'; - // eslint-disable-next-line import/no-unresolved import {PluginRegistry} from './types/mattermost-webapp'; +import App from './app'; +import manifest from './manifest'; export default class Plugin { public async initialize(registry: PluginRegistry, store: Store>>) { - // @see https://developers.mattermost.com/extend/plugins/webapp/reference/ registry.registerReducer(reducer); + registry.registerRootComponent(App); registry.registerRootComponent(TaskModal); registry.registerRootComponent(LinkModal); - const {showRHSPlugin} = registry.registerRightHandSidebarComponent(App, Constants.RightSidebarHeader); + registry.registerWebSocketEventHandler(`custom_${Constants.pluginId}_connect`, handleConnect(store)); + registry.registerWebSocketEventHandler(`custom_${Constants.pluginId}_disconnect`, handleDisconnect(store)); + const {showRHSPlugin} = registry.registerRightHandSidebarComponent(Rhs, Constants.RightSidebarHeader); const hooks = new Hooks(store); registry.registerSlashCommandWillBePostedHook(hooks.slashCommandWillBePostedHook); registry.registerChannelHeaderButtonAction(, () => store.dispatch(showRHSPlugin), null, Constants.AzureDevops); diff --git a/webapp/src/reducers/globalModal/index.ts b/webapp/src/reducers/globalModal/index.ts new file mode 100644 index 00000000..a1323845 --- /dev/null +++ b/webapp/src/reducers/globalModal/index.ts @@ -0,0 +1,25 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; + +const initialState: GlobalModalState = { + modalId: null, + commandArgs: [], +}; + +export const globalModalSlice = createSlice({ + name: 'globalModal', + initialState, + reducers: { + setGlobalModalState: (state: GlobalModalState, action: PayloadAction) => { + state.modalId = action.payload.modalId; + state.commandArgs = action.payload.commandArgs; + }, + resetGlobalModalState: (state: GlobalModalState) => { + state.modalId = null; + state.commandArgs = []; + }, + }, +}); + +export const {setGlobalModalState, resetGlobalModalState} = globalModalSlice.actions; + +export default globalModalSlice.reducer; diff --git a/webapp/src/reducers/index.ts b/webapp/src/reducers/index.ts index 3acc73a4..37688081 100644 --- a/webapp/src/reducers/index.ts +++ b/webapp/src/reducers/index.ts @@ -2,15 +2,19 @@ import {combineReducers} from 'redux'; import services from 'services'; -import openLinkModalReducer from './linkModal'; -import openTaskModalReducer from './taskModal'; +import globalModalSlice from './globalModal'; +import openLinkModalSlice from './linkModal'; +import openTaskModalSlice from './taskModal'; import projectDetailsSlice from './projectDetails'; +import userConnectedSlice from './userConnected'; import testReducer from './testReducer'; const reducers = combineReducers({ - openLinkModalReducer, - openTaskModalReducer, + globalModalSlice, + openLinkModalSlice, + openTaskModalSlice, projectDetailsSlice, + userConnectedSlice, testReducer, [services.reducerPath]: services.reducer, }); diff --git a/webapp/src/reducers/linkModal/index.ts b/webapp/src/reducers/linkModal/index.ts index e0014cc6..6c7ee362 100644 --- a/webapp/src/reducers/linkModal/index.ts +++ b/webapp/src/reducers/linkModal/index.ts @@ -1,6 +1,6 @@ import {createSlice, PayloadAction} from '@reduxjs/toolkit'; -import {getProjectLinkDetails} from 'utils'; +import {getProjectLinkModalArgs} from 'utils'; const initialState: LinkProjectModalState = { visibility: false, @@ -13,21 +13,17 @@ export const openLinkModalSlice = createSlice({ name: 'openLinkModal', initialState, reducers: { - showLinkModal: (state: LinkProjectModalState, action: PayloadAction>) => { - if (action.payload.length > 2) { - const details = getProjectLinkDetails(action.payload[2]); - if (details.length === 2) { - state.organization = details[0]; - state.project = details[1]; - } - } - state.visibility = true; - state.isLinked = false; - }, - hideLinkModal: (state: LinkProjectModalState) => { - state.visibility = false; + toggleShowLinkModal: (state: LinkProjectModalState, action: PayloadAction) => { + state.visibility = action.payload.isVisible; state.organization = ''; state.project = ''; + state.isLinked = false; + + if (action.payload.commandArgs.length > 0) { + const {organization, project} = getProjectLinkModalArgs(action.payload.commandArgs[0]) as LinkPayload; + state.organization = organization; + state.project = project; + } }, toggleIsLinked: (state: LinkProjectModalState, action: PayloadAction) => { state.isLinked = action.payload; @@ -35,6 +31,6 @@ export const openLinkModalSlice = createSlice({ }, }); -export const {showLinkModal, hideLinkModal, toggleIsLinked} = openLinkModalSlice.actions; +export const {toggleShowLinkModal, toggleIsLinked} = openLinkModalSlice.actions; export default openLinkModalSlice.reducer; diff --git a/webapp/src/reducers/taskModal/index.ts b/webapp/src/reducers/taskModal/index.ts index 969bd53f..1887fec2 100644 --- a/webapp/src/reducers/taskModal/index.ts +++ b/webapp/src/reducers/taskModal/index.ts @@ -1,10 +1,6 @@ import {createSlice} from '@reduxjs/toolkit'; -export interface CreateTaskModal { - visibility: boolean -} - -const initialState: CreateTaskModal = { +const initialState: CreateTaskModalState = { visibility: false, }; diff --git a/webapp/src/reducers/userConnected/index.ts b/webapp/src/reducers/userConnected/index.ts new file mode 100644 index 00000000..2ec54651 --- /dev/null +++ b/webapp/src/reducers/userConnected/index.ts @@ -0,0 +1,23 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; + +type UserConnectedState = { + isConnected: boolean; +}; + +const initialState: UserConnectedState = { + isConnected: false, +}; + +export const userConnectedSlice = createSlice({ + name: 'userConnected', + initialState, + reducers: { + toggleIsConnected: (state: UserConnectedState, action: PayloadAction) => { + state.isConnected = action.payload; + }, + }, +}); + +export const {toggleIsConnected} = userConnectedSlice.actions; + +export default userConnectedSlice.reducer; diff --git a/webapp/src/selectors/index.tsx b/webapp/src/selectors/index.tsx index 05f8cb77..316c00ce 100644 --- a/webapp/src/selectors/index.tsx +++ b/webapp/src/selectors/index.tsx @@ -6,6 +6,14 @@ export const getRhsState = (state: ReduxState): {isSidebarOpen: boolean} => { return state.views.rhs; }; +export const getGlobalModalState = (state: ReduxState): GlobalModalState => { + return state['plugins-mattermost-plugin-azure-devops'].globalModalSlice; +}; + export const getLinkModalState = (state: ReduxState): LinkProjectModalState => { - return state['plugins-mattermost-plugin-azure-devops'].openLinkModalReducer; + return state['plugins-mattermost-plugin-azure-devops'].openLinkModalSlice; +}; + +export const getTaskModalState = (state: ReduxState): CreateTaskModalState => { + return state['plugins-mattermost-plugin-azure-devops'].openTaskModalSlice; }; diff --git a/webapp/src/types/common/index.d.ts b/webapp/src/types/common/index.d.ts index 09a22791..763d244d 100644 --- a/webapp/src/types/common/index.d.ts +++ b/webapp/src/types/common/index.d.ts @@ -12,15 +12,6 @@ type PluginApiService = { apiServiceName: ApiServiceName } -interface ReduxState extends GlobalState { - views: { - rhs: { - isSidebarOpen: boolean - } - } - 'plugins-mattermost-plugin-azure-devops': RootState<{ [x: string]: QueryDefinition, never, WellList[], 'pluginApi'>; }, never, 'pluginApi'> -} - type TabData = { title: string, tabPanel: JSX.Element @@ -73,3 +64,10 @@ type SubscriptionDetails = { name: string eventType: eventType } + +type ModalId = 'linkProject' | 'createBoardTask' | null + +type WebsocketEventParams = { + event: string, + data: Record, +} diff --git a/webapp/src/types/common/store.d.ts b/webapp/src/types/common/store.d.ts index db390532..26cdd1e4 100644 --- a/webapp/src/types/common/store.d.ts +++ b/webapp/src/types/common/store.d.ts @@ -1,6 +1,29 @@ +interface ReduxState extends GlobalState { + views: { + rhs: { + isSidebarOpen: boolean + } + } + 'plugins-mattermost-plugin-azure-devops': RootState<{ [x: string]: QueryDefinition, never, WellList[], 'pluginApi'>; }, never, 'pluginApi'> +} + +type GlobalModalState = { + modalId: ModalId + commandArgs: Array +} + +type GlobalModalActionPayload = { + isVisible: boolean + commandArgs: Array +} + type LinkProjectModalState = { visibility: boolean, organization: string, project: string, isLinked: boolean, } + +type CreateTaskModalState = { + visibility: boolean +} diff --git a/webapp/src/types/mattermost-webapp/index.d.ts b/webapp/src/types/mattermost-webapp/index.d.ts index f94de6f8..40215e27 100644 --- a/webapp/src/types/mattermost-webapp/index.d.ts +++ b/webapp/src/types/mattermost-webapp/index.d.ts @@ -12,4 +12,5 @@ export interface PluginRegistry { registerChannelHeaderMenuAction(text: string, action: () => void); registerRightHandSidebarComponent(component: () => JSX.Element, title: string | JSX.Element); registerChannelHeaderButtonAction(icon: JSX.Element, action: () => void, dropdownText: string | null, tooltipText: string | null); + registerWebSocketEventHandler(event: string, handler: (msg: WebsocketEventParams) => void) } diff --git a/webapp/src/utils/index.ts b/webapp/src/utils/index.ts index 4271bf39..a38446e1 100644 --- a/webapp/src/utils/index.ts +++ b/webapp/src/utils/index.ts @@ -14,7 +14,7 @@ const getBaseUrls = (): {pluginApiBaseUrl: string; mattermostApiBaseUrl: string} return {pluginApiBaseUrl, mattermostApiBaseUrl}; }; -export const splitArgs = (command: string) => { +export const getCommandArgs = (command: string) => { const myRegexp = /[^\s"]+|"([^"]*)"/gi; const myArray = []; let match; @@ -24,19 +24,22 @@ export const splitArgs = (command: string) => { myArray.push(match[1] ? match[1] : match[0]); } } while (match != null); - return myArray; + return myArray.length > 2 ? myArray.slice(2) : []; }; -export const getProjectLinkDetails = (str: string) => { +export const getProjectLinkModalArgs = (str: string): LinkPayload => { const data = str.split('/'); - if (data.length !== 5) { - return []; + if (data.length < 5 || (data[0] !== 'https:' && data[2] !== 'dev.azure.com')) { + return { + organization: '', + project: '', + }; } - if (data[0] !== 'https:' && data[2] !== 'dev.azure.com') { - return []; - } - const values = [data[3], data[4]]; - return values; + + return { + organization: data[3] ?? '', + project: data[4] ?? '', + }; }; export const onPressingEnterKey = (event: Event | undefined, func: () => void) => { diff --git a/webapp/src/websocket/index.ts b/webapp/src/websocket/index.ts new file mode 100644 index 00000000..2fe11f1e --- /dev/null +++ b/webapp/src/websocket/index.ts @@ -0,0 +1,16 @@ +import {Store, Action} from 'redux'; +import {GlobalState} from 'mattermost-redux/types/store'; + +import {toggleIsConnected} from 'reducers/userConnected'; + +export function handleConnect(store: Store>>) { + return (_: WebsocketEventParams) => { + store.dispatch(toggleIsConnected(true) as Action); + }; +} + +export function handleDisconnect(store: Store>>) { + return (_: WebsocketEventParams) => { + store.dispatch(toggleIsConnected(false) as Action); + }; +} From 023ebcb31bdfbec21e06aa31aace5ec40eb430da Mon Sep 17 00:00:00 2001 From: Abhishek Verma <72438220+avas27JTG@users.noreply.github.com> Date: Fri, 2 Sep 2022 16:33:27 +0530 Subject: [PATCH 4/8] [MI-2035]: Integrated unlinking project from details page (#33) * [MI-1986]: Create plugin API to fetch linked projects list * [MI-1987]: Integrated project list UI * [MI-1987]: Review fixes * [MI-2001]: [MI-2001]: Create plugin API to unlink project and integrate the UI * [MI-2001]: Review fixes * [MI-2002]: Created plugin API to fetch user details and UI integration with other changes * [MI-2002]: Updated API paths * [MI-2049]: Added websocket support to detect user connection details and a centralised check for root modals * [MI-2035]: Integrated unlinking project from details page * [MI-2035]: Review fix * [MI-2035]: Review fix Co-authored-by: Abhishek Verma --- server/plugin/api.go | 7 ++ .../components/buttons/iconButton/index.tsx | 36 +++++----- webapp/src/components/emptyState/index.tsx | 72 ++++++++++++++----- .../modal/confirmationModal/index.tsx | 5 +- webapp/src/components/modal/index.tsx | 2 +- webapp/src/containers/LinkModal/index.tsx | 1 + webapp/src/containers/Rhs/index.tsx | 2 +- .../containers/Rhs/projectDetails/index.tsx | 48 +++++++++++-- .../src/containers/Rhs/projectList/index.tsx | 34 +++++++++ webapp/src/hooks/index.js | 1 + webapp/src/utils/index.ts | 2 +- 11 files changed, 164 insertions(+), 46 deletions(-) diff --git a/server/plugin/api.go b/server/plugin/api.go index cc1b33e4..910cf469 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -148,6 +148,13 @@ func (p *Plugin) handleGetAllLinkedProjects(w http.ResponseWriter, r *http.Reque return } + w.Header().Add("Content-Type", "application/json") + + if projectList == nil { + _, _ = w.Write([]byte("[]")) + return + } + response, err := json.Marshal(projectList) if err != nil { p.API.LogError(constants.ErrorFetchProjectList, "Error", err.Error()) diff --git a/webapp/src/components/buttons/iconButton/index.tsx b/webapp/src/components/buttons/iconButton/index.tsx index f1272702..d17720c3 100644 --- a/webapp/src/components/buttons/iconButton/index.tsx +++ b/webapp/src/components/buttons/iconButton/index.tsx @@ -3,6 +3,8 @@ import {Button} from 'react-bootstrap'; import Tooltip from 'components/tooltip'; +import {onPressingEnterKey} from 'utils'; + import './styles.scss'; type IconColor = 'danger' @@ -15,21 +17,23 @@ type IconButtonProps = { onClick?: () => void } -const IconButton = ({tooltipText, iconClassName, extraClass = '', iconColor, onClick}: IconButtonProps) => { - return ( - -