From 9f65a10331b121993ed775abfe5a9b5fb85c263e Mon Sep 17 00:00:00 2001 From: Abhishek Verma Date: Thu, 11 Aug 2022 11:27:24 +0530 Subject: [PATCH 01/28] [MI-1986]: Create plugin API to fetch linked projects list --- server/constants/routes.go | 9 +++++---- server/plugin/api.go | 27 +++++++++++++++++++++++++++ server/serializers/project.go | 28 ++++------------------------ 3 files changed, 36 insertions(+), 28 deletions(-) diff --git a/server/constants/routes.go b/server/constants/routes.go index e4f50799..18f1a390 100644 --- a/server/constants/routes.go +++ b/server/constants/routes.go @@ -2,8 +2,9 @@ package constants const ( // Plugin API Routes - APIPrefix = "/api/v1" - WildRoute = "{anything:.*}" - PathOAuthConnect = "/oauth/connect" - PathOAuthCallback = "/oauth/complete" + APIPrefix = "/api/v1" + WildRoute = "{anything:.*}" + PathOAuthConnect = "/oauth/connect" + PathOAuthCallback = "/oauth/complete" + PathGetAllLinkedProjects = "/link/project" ) diff --git a/server/plugin/api.go b/server/plugin/api.go index ed87c8f2..75c4fa41 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -36,6 +36,7 @@ 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) } // API to create task of a project in an organization. @@ -124,6 +125,32 @@ func (p *Plugin) handleLink(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "application/json") } +// handleGetAllLinkedProjects returns all linked projects list +func (p *Plugin) handleGetAllLinkedProjects(w http.ResponseWriter, r *http.Request) { + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + 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) + 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) + 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) + } +} + // handleAuthRequired verifies if the provided request is performed by an authorized source. func (p *Plugin) handleAuthRequired(handleFunc http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/server/serializers/project.go b/server/serializers/project.go index 44a477f0..21272ecb 100644 --- a/server/serializers/project.go +++ b/server/serializers/project.go @@ -1,28 +1,8 @@ package serializers type ProjectDetails struct { - MattermostUserID string - ProjectID string - ProjectName string - OrganizationName string + MattermostUserID string `json:"mattermostUserID"` + ProjectID string `json:"projectID"` + ProjectName string `json:"projectName"` + OrganizationName string `json:"organizationName"` } - -// TODO: Remove later if not needed. -// import ( -// "time" -// ) - -// type ProjectList struct { -// Count int `json:"count"` -// ProjectValue []ProjectValue `json:"value"` -// } - -// type ProjectValue struct { -// ID string `json:"id"` -// URL string `json:"url"` -// Name string `json:"name"` -// State string `json:"state"` -// Revision int `json:"revision"` -// Visibility string `json:"visibility"` -// LastUpdateTime time.Time `json:"lastUpdateTime"` -// } From e1793e45004fac2946960507e25363683c2c3856 Mon Sep 17 00:00:00 2001 From: Abhishek Verma Date: Thu, 11 Aug 2022 11:44:55 +0530 Subject: [PATCH 02/28] [MI-1987]: Integrated project list UI --- .../components/buttons/iconButton/index.tsx | 3 +- webapp/src/components/card/project/index.tsx | 14 +++-- webapp/src/components/emptyState/index.tsx | 53 ++++++++++++++++ .../emptyState}/styles.scss | 20 ++++-- .../modal/confirmationModal/index.tsx | 29 +++++++++ webapp/src/components/modal/index.tsx | 10 +-- .../modal/subComponents/modalFooter/index.tsx | 10 +-- .../subComponents/modalFooter/styles.scss | 5 ++ .../modal/subComponents/modalHeader/index.tsx | 2 +- webapp/src/containers/Rhs/index.tsx | 12 ++-- .../src/containers/Rhs/projectList/index.tsx | 63 ++++++++++++------- .../src/containers/Rhs/tabs/no_data/index.tsx | 39 ------------ webapp/src/index.tsx | 2 +- webapp/src/plugin_constants/index.ts | 5 ++ webapp/src/reducers/linkModal/index.ts | 6 +- webapp/src/reducers/projectDetails/index.ts | 18 +++--- webapp/src/selectors/index.tsx | 4 ++ webapp/src/services/index.ts | 11 +++- webapp/src/styles/_components.scss | 7 +++ webapp/src/styles/_utils.scss | 2 +- webapp/src/types/common/index.d.ts | 10 +-- 21 files changed, 217 insertions(+), 108 deletions(-) create mode 100644 webapp/src/components/emptyState/index.tsx rename webapp/src/{containers/Rhs/tabs/no_data => components/emptyState}/styles.scss (53%) create mode 100644 webapp/src/components/modal/confirmationModal/index.tsx delete mode 100644 webapp/src/containers/Rhs/tabs/no_data/index.tsx 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 53% rename from webapp/src/containers/Rhs/tabs/no_data/styles.scss rename to webapp/src/components/emptyState/styles.scss index c51f552d..fb2a5bbf 100644 --- a/webapp/src/containers/Rhs/tabs/no_data/styles.scss +++ b/webapp/src/components/emptyState/styles.scss @@ -1,11 +1,13 @@ .no-data { - text-align: center; height: 100%; - min-height: 350px; + display: flex; + 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 +26,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); +} \ No newline at end of file diff --git a/webapp/src/components/modal/confirmationModal/index.tsx b/webapp/src/components/modal/confirmationModal/index.tsx new file mode 100644 index 00000000..230d4cd2 --- /dev/null +++ b/webapp/src/components/modal/confirmationModal/index.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import Modal from 'components/modal'; + +type ConfirmationModalProps = { + isOpen: boolean + title: string + description: string + confirmBtnText: string + onHide?: () => void + onConfirm?: () => void +} + +const ConfirmationModal = ({isOpen, title, confirmBtnText, description, onHide, onConfirm}: ConfirmationModalProps) => { + return ( + +

{description}

+
+ ); +}; + +export default ConfirmationModal; diff --git a/webapp/src/components/modal/index.tsx b/webapp/src/components/modal/index.tsx index 320a5540..1d580b25 100644 --- a/webapp/src/components/modal/index.tsx +++ b/webapp/src/components/modal/index.tsx @@ -9,7 +9,7 @@ import ModalSubTitleAndError from './subComponents/modalSubtitleAndError'; type ModalProps = { show: boolean; - onHide: () => void; + onHide?: () => void; showCloseIconInHeader?: boolean; children?: JSX.Element; title?: string | JSX.Element; @@ -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 ( + confirmAction={confirmAction} + /> ); }; diff --git a/webapp/src/components/modal/subComponents/modalFooter/index.tsx b/webapp/src/components/modal/subComponents/modalFooter/index.tsx index 88bfcb18..d3606ad4 100644 --- a/webapp/src/components/modal/subComponents/modalFooter/index.tsx +++ b/webapp/src/components/modal/subComponents/modalFooter/index.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {Modal as RBModal} from 'react-bootstrap'; +import {Button} from 'react-bootstrap'; import './styles.scss'; @@ -11,13 +12,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/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..2bdb6ff8 100644 --- a/webapp/src/plugin_constants/index.ts +++ b/webapp/src/plugin_constants/index.ts @@ -28,6 +28,11 @@ const pluginApiServiceConfigs: Record = { method: 'GET', apiServiceName: 'testGet', }, + getAllLinkedProjectsList: { + path: '/link/project', + method: 'GET', + apiServiceName: 'getAllLinkedProjectsList' + } }; export default { diff --git a/webapp/src/reducers/linkModal/index.ts b/webapp/src/reducers/linkModal/index.ts index 539aa8ca..15b264dd 100644 --- a/webapp/src/reducers/linkModal/index.ts +++ b/webapp/src/reducers/linkModal/index.ts @@ -1,4 +1,4 @@ -import {createSlice} from '@reduxjs/toolkit'; +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; import {getProjectLinkDetails} from 'utils'; @@ -18,7 +18,7 @@ export const openLinkModalSlice = createSlice({ name: 'openLinkModal', initialState, reducers: { - showLinkModal: (state, action) => { + showLinkModal: (state: CreateTaskModal, action: PayloadAction>) => { if (action.payload.length > 2) { const details = getProjectLinkDetails(action.payload[2]); if (details.length === 2) { @@ -28,7 +28,7 @@ export const openLinkModalSlice = createSlice({ } state.visibility = true; }, - hideLinkModal: (state) => { + hideLinkModal: (state: CreateTaskModal) => { state.visibility = false; state.organization = ''; state.project = ''; diff --git a/webapp/src/reducers/projectDetails/index.ts b/webapp/src/reducers/projectDetails/index.ts index 728bd859..585111ae 100644 --- a/webapp/src/reducers/projectDetails/index.ts +++ b/webapp/src/reducers/projectDetails/index.ts @@ -1,9 +1,9 @@ import {createSlice, PayloadAction} from '@reduxjs/toolkit'; const initialState: ProjectDetails = { - id: '', - organization: '', - title: '', + projectID: '', + projectName: '', + organizationName: '', }; export const projectDetailsSlice = createSlice({ @@ -11,14 +11,14 @@ 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.projectID = action.payload.projectID; + state.projectName = action.payload.projectName; + state.organizationName = action.payload.organizationName; }, resetProjectDetails: (state: ProjectDetails) => { - state.id = ''; - state.title = ''; - state.organization = ''; + state.projectID = ''; + state.projectName = ''; + state.organizationName = ''; }, }, }); diff --git a/webapp/src/selectors/index.tsx b/webapp/src/selectors/index.tsx index 6ca79d19..67e9f99c 100644 --- a/webapp/src/selectors/index.tsx +++ b/webapp/src/selectors/index.tsx @@ -5,3 +5,7 @@ const pluginPrefix = `plugins-${plugin_constants.pluginId}`; export const getprojectDetailsState = (state: any) => { return state[pluginPrefix].projectDetailsSlice; }; + +export const getRhsState = (state: any): {isSidebarOpen: boolean} => { + return state.views.rhs; +}; diff --git a/webapp/src/services/index.ts b/webapp/src/services/index.ts index 70c64ee5..1219b173 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,13 @@ 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, + }), + }), }), }); 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/_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..139d6159 100644 --- a/webapp/src/types/common/index.d.ts +++ b/webapp/src/types/common/index.d.ts @@ -4,11 +4,11 @@ type HttpMethod = 'GET' | 'POST'; -type ApiServiceName = 'createTask' | 'testGet' | 'createLink' +type ApiServiceName = 'createTask' | 'testGet' | 'createLink' | 'getAllLinkedProjectsList' type PluginApiService = { path: string, - method: httpMethod, + method: HttpMethod, apiServiceName: ApiServiceName } @@ -51,9 +51,9 @@ type TabsData = { } type ProjectDetails = { - id: string - title: string - organization: string + projectID: string, + projectName: string, + organizationName: string } type eventType = 'create' | 'update' | 'delete' From 0110bdc69be8b8895ac68d7eb8c484a9b7a73489 Mon Sep 17 00:00:00 2001 From: Abhishek Verma Date: Thu, 11 Aug 2022 12:10:41 +0530 Subject: [PATCH 03/28] [MI-1987]: Review fixes --- webapp/src/components/card/project/index.tsx | 2 - .../src/components/card/project/styles.scss | 16 ------- .../components/card/subscription/styles.scss | 8 ---- webapp/src/components/emptyState/index.tsx | 6 +-- webapp/src/components/emptyState/styles.scss | 3 +- .../src/components/labelValuePair/index.tsx | 2 +- .../modal/confirmationModal/index.tsx | 24 +++++------ webapp/src/components/modal/index.tsx | 2 +- .../modal/subComponents/modalFooter/index.tsx | 3 +- webapp/src/containers/Rhs/index.tsx | 2 +- .../src/containers/Rhs/projectList/index.tsx | 42 ++++++++++++------- .../containers/Rhs/tabs/connections/.gitkeep | 0 .../containers/Rhs/tabs/connections/index.tsx | 17 -------- webapp/src/containers/Rhs/tabs/index.tsx | 31 -------------- webapp/src/containers/Rhs/tabs/styles.scss | 39 ----------------- .../Rhs/tabs/subscriptions/index.tsx | 18 -------- webapp/src/plugin_constants/index.ts | 4 +- webapp/src/styles/_global.scss | 8 ++++ webapp/src/utils/index.ts | 2 +- 19 files changed, 58 insertions(+), 171 deletions(-) delete mode 100644 webapp/src/components/card/project/styles.scss 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/styles.scss delete mode 100644 webapp/src/containers/Rhs/tabs/subscriptions/index.tsx diff --git a/webapp/src/components/card/project/index.tsx b/webapp/src/components/card/project/index.tsx index d0f8fd44..a53c35a6 100644 --- a/webapp/src/components/card/project/index.tsx +++ b/webapp/src/components/card/project/index.tsx @@ -5,8 +5,6 @@ import IconButton from 'components/buttons/iconButton'; import {onPressingEnterKey} from 'utils'; -import './styles.scss'; - type ProjectCardProps = { onProjectTitleClick: (projectDetails: ProjectDetails) => void handleUnlinkProject: () => void diff --git a/webapp/src/components/card/project/styles.scss b/webapp/src/components/card/project/styles.scss deleted file mode 100644 index 5394c23d..00000000 --- a/webapp/src/components/card/project/styles.scss +++ /dev/null @@ -1,16 +0,0 @@ -.project-details { - flex-basis: 70%; -} - -.button-wrapper { - flex-basis: 30%; -} - -.delete-button { - display: block; - margin-left: auto; - - i { - margin: 1px 0 0 2px; - } -} diff --git a/webapp/src/components/card/subscription/styles.scss b/webapp/src/components/card/subscription/styles.scss index 5394c23d..c3ae5448 100644 --- a/webapp/src/components/card/subscription/styles.scss +++ b/webapp/src/components/card/subscription/styles.scss @@ -1,11 +1,3 @@ -.project-details { - flex-basis: 70%; -} - -.button-wrapper { - flex-basis: 30%; -} - .delete-button { display: block; margin-left: auto; diff --git a/webapp/src/components/emptyState/index.tsx b/webapp/src/components/emptyState/index.tsx index dc9aae90..43434621 100644 --- a/webapp/src/components/emptyState/index.tsx +++ b/webapp/src/components/emptyState/index.tsx @@ -12,9 +12,9 @@ type EmptyStatePropTypes = { buttonAction?: (event: React.SyntheticEvent) => void; } -const EmptyState = ({ title, subTitle, buttonText, buttonAction }: EmptyStatePropTypes) => { +const EmptyState = ({title, subTitle, buttonText, buttonAction}: EmptyStatePropTypes) => { return ( -
+
- +

{title}

diff --git a/webapp/src/components/emptyState/styles.scss b/webapp/src/components/emptyState/styles.scss index fb2a5bbf..c55d2588 100644 --- a/webapp/src/components/emptyState/styles.scss +++ b/webapp/src/components/emptyState/styles.scss @@ -1,6 +1,5 @@ .no-data { height: 100%; - display: flex; justify-content: center; margin-top: 80px; text-align: center; @@ -36,4 +35,4 @@ padding: 10px 15px; border-radius: 4px; border: 1px solid rgba(var(--center-channel-color-rgb), 0.08); -} \ No newline at end of file +} 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 index 230d4cd2..a6a665f1 100644 --- a/webapp/src/components/modal/confirmationModal/index.tsx +++ b/webapp/src/components/modal/confirmationModal/index.tsx @@ -12,18 +12,18 @@ type ConfirmationModalProps = { } const ConfirmationModal = ({isOpen, title, confirmBtnText, description, onHide, onConfirm}: ConfirmationModalProps) => { - return ( - -

{description}

-
- ); + return ( + +

{description}

+
+ ); }; export default ConfirmationModal; diff --git a/webapp/src/components/modal/index.tsx b/webapp/src/components/modal/index.tsx index 1d580b25..0b536dd0 100644 --- a/webapp/src/components/modal/index.tsx +++ b/webapp/src/components/modal/index.tsx @@ -56,7 +56,7 @@ const Modal = ({show, onHide, showCloseIconInHeader = true, children, title, sub cancelBtnText={cancelBtnText} confirmBtnText={confirmBtnText} confirmAction={confirmAction} - /> + /> ); }; diff --git a/webapp/src/components/modal/subComponents/modalFooter/index.tsx b/webapp/src/components/modal/subComponents/modalFooter/index.tsx index d3606ad4..82c8eaba 100644 --- a/webapp/src/components/modal/subComponents/modalFooter/index.tsx +++ b/webapp/src/components/modal/subComponents/modalFooter/index.tsx @@ -1,6 +1,5 @@ import React from 'react'; import {Modal as RBModal} from 'react-bootstrap'; -import {Button} from 'react-bootstrap'; import './styles.scss'; @@ -16,7 +15,7 @@ type ModalFooterProps = { } const ModalFooter = ({onConfirm, onHide, cancelBtnText, confirmBtnText, className = '', confirmDisabled, cancelDisabled, confirmAction}: ModalFooterProps) : JSX.Element => ( - + {onConfirm && (
diff --git a/webapp/src/components/modal/confirmationModal/index.tsx b/webapp/src/components/modal/confirmationModal/index.tsx index a6a665f1..4df6e734 100644 --- a/webapp/src/components/modal/confirmationModal/index.tsx +++ b/webapp/src/components/modal/confirmationModal/index.tsx @@ -9,9 +9,10 @@ type ConfirmationModalProps = { confirmBtnText: string onHide?: () => void onConfirm?: () => void + isLoading?: boolean } -const ConfirmationModal = ({isOpen, title, confirmBtnText, description, onHide, onConfirm}: ConfirmationModalProps) => { +const ConfirmationModal = ({isOpen, title, confirmBtnText, description, onHide, onConfirm, isLoading}: ConfirmationModalProps) => { return (

{description}

diff --git a/webapp/src/containers/LinkModal/index.tsx b/webapp/src/containers/LinkModal/index.tsx index 474fb11c..b7cfc245 100644 --- a/webapp/src/containers/LinkModal/index.tsx +++ b/webapp/src/containers/LinkModal/index.tsx @@ -1,138 +1,126 @@ -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {useDispatch} from 'react-redux'; import Input from 'components/inputField'; import Modal from 'components/modal'; -import Constants from 'plugin_constants'; import usePluginApi from 'hooks/usePluginApi'; -import {hideLinkModal} from 'reducers/linkModal'; +import {hideLinkModal, toggleIsLinked} from 'reducers/linkModal'; +import {getLinkModalState} from 'selectors'; +import plugin_constants from 'plugin_constants'; const LinkModal = () => { - const [state, setState] = useState({ - linkOrganization: '', - linkProject: '', + // State variables + const [projectDetails, setProjectDetails] = useState({ + organization: '', + project: '', }); - const [error, setError] = useState({ - linkOrganizationError: '', - linkProjectError: '', + + const [errorState, setErrorState] = useState({ + organization: '', + project: '', }); - const [linkPayload, setLinkPayload] = useState(); + + // Hooks const usePlugin = usePluginApi(); - const {visibility, organization, project} = usePlugin.state['plugins-mattermost-plugin-azure-devops'].openLinkModalReducer; - const [loading, setLoading] = useState(false); - const [APIError, setAPIError] = useState(''); const dispatch = useDispatch(); - useEffect(() => { - if (organization && project) { - setState({ - linkOrganization: organization, - linkProject: project, - }); - } - }, [visibility]); - // Function to hide the modal and reset all the states. - const onHide = useCallback(() => { - setState({ - linkOrganization: '', - linkProject: '', + const resetModalState = () => { + setProjectDetails({ + organization: '', + project: '', }); - setError({ - linkOrganizationError: '', - linkProjectError: '', + setErrorState({ + organization: '', + project: '', }); - setLinkPayload(null); - setLoading(false); - setAPIError(''); dispatch(hideLinkModal()); - }, []); + }; + + // Set organization name + const onOrganizationChange = (e: React.ChangeEvent) => { + setErrorState({...errorState, organization: ''}); + setProjectDetails({...projectDetails, organization: (e.target as HTMLInputElement).value}); + }; - const onOrganizationChange = useCallback((e: React.ChangeEvent) => { - setError({...error, linkOrganizationError: ''}); - setState({...state, linkOrganization: (e.target as HTMLInputElement).value}); - }, [state, error]); + // Set project name + const onProjectChange = (e: React.ChangeEvent) => { + setErrorState({...errorState, project: ''}); + setProjectDetails({...projectDetails, project: (e.target as HTMLInputElement).value}); + }; - const onProjectChange = useCallback((e: React.ChangeEvent) => { - setError({...error, linkProjectError: ''}); - setState({...state, linkProject: (e.target as HTMLInputElement).value}); - }, [state, error]); + // Handles on confirming link project + const onConfirm = () => { + const errorStateChanges: LinkPayload = { + organization: '', + project: '', + }; - const onConfirm = useCallback(() => { - if (state.linkOrganization === '') { - setError((value) => ({...value, linkOrganizationError: 'Organization is required'})); + if (projectDetails.organization === '') { + errorStateChanges.organization = 'Organization is required'; } - if (state.linkProject === '') { - setError((value) => ({...value, linkProjectError: 'Project is required'})); + + if (projectDetails.project === '') { + errorStateChanges.project = 'Project is required'; } - if (!state.linkOrganization || !state.linkProject) { + if (errorStateChanges.organization || errorStateChanges.project) { + setErrorState(errorStateChanges); return; } - // Create payload to send in the POST request. - const payload = { - organization: state.linkOrganization, - project: state.linkProject, - }; - - // TODO: save the payload in a state variable to use it while reading the state - // we can see later if there exists a better way for this - setLinkPayload(payload); - // Make POST api request - usePlugin.makeApiRequest(Constants.pluginApiServiceConfigs.createLink.apiServiceName, payload); - }, [state]); + linkTask(projectDetails); + }; - useEffect(() => { - if (linkPayload) { - const {isLoading, isSuccess, isError} = usePlugin.getApiState(Constants.pluginApiServiceConfigs.createLink.apiServiceName, linkPayload); - setLoading(isLoading); - if (isSuccess) { - onHide(); - } - if (isError) { - setAPIError('Organization or project name is wrong'); - } + // Make POST api request to link a project + const linkTask = async (payload: LinkPayload) => { + const createTaskRequest = await usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.createLink.apiServiceName, payload); + if (createTaskRequest) { + dispatch(toggleIsLinked(true)); + resetModalState(); } - }, [usePlugin.state]); - - if (visibility) { - return ( - - <> - - - - - ); - } - return null; + }; + + useEffect(() => { + setProjectDetails({ + organization: getLinkModalState(usePlugin.state).organization, + project: getLinkModalState(usePlugin.state).project, + }); + }, [getLinkModalState(usePlugin.state)]); + + return ( + + <> + + + + + ); }; export default LinkModal; diff --git a/webapp/src/containers/Rhs/projectList/index.tsx b/webapp/src/containers/Rhs/projectList/index.tsx index a5021992..48e95089 100644 --- a/webapp/src/containers/Rhs/projectList/index.tsx +++ b/webapp/src/containers/Rhs/projectList/index.tsx @@ -7,27 +7,65 @@ import LinearLoader from 'components/loader/linear'; import ConfirmationModal from 'components/modal/confirmationModal'; import {setProjectDetails} from 'reducers/projectDetails'; -import {showLinkModal} from 'reducers/linkModal'; +import {showLinkModal, toggleIsLinked} from 'reducers/linkModal'; +import {getLinkModalState} from 'selectors'; import usePluginApi from 'hooks/usePluginApi'; import plugin_constants from 'plugin_constants'; const ProjectList = () => { + // State variables const [showConfirmationModal, setShowConfirmationModal] = useState(false); + const [projectToBeUnlinked, setProjectToBeUnlinked] = useState(); + + // Hooks const dispatch = useDispatch(); const usePlugin = usePluginApi(); + // Fetch linked projects list + const fetchLinkedProjectsList = () => usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName); + + // Navigates to project details view const handleProjectTitleClick = (projectDetails: ProjectDetails) => { dispatch(setProjectDetails(projectDetails)); }; + // Opens link project modal const handleOpenLinkProjectModal = () => { dispatch(showLinkModal([])); }; + /** + * Opens a confirmation modal to confirm unlinking a project + * @param projectDetails + */ + const handleUnlinkProject = (projectDetails: ProjectDetails) => { + setProjectToBeUnlinked(projectDetails); + setShowConfirmationModal(true); + }; + + // Handles unlinking a project and fetching the modified project list + const handleConfirmUnlinkProject = async () => { + const unlinkProjectStatus = await usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.unlinkProject.apiServiceName, projectToBeUnlinked); + + if (unlinkProjectStatus) { + fetchLinkedProjectsList(); + setShowConfirmationModal(false); + } + }; + + // Fetch the linked projects list when RHS is opened useEffect(() => { - usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName); + fetchLinkedProjectsList(); }, []); + // Fetch the linked projects list when new project is linked + useEffect(() => { + if (getLinkModalState(usePlugin.state).isLinked) { + dispatch(toggleIsLinked(false)); + fetchLinkedProjectsList(); + } + }, [getLinkModalState(usePlugin.state)]); + const data = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName).data; return ( @@ -37,9 +75,10 @@ const ProjectList = () => { setShowConfirmationModal(false)} - onConfirm={() => setShowConfirmationModal(false)} + onConfirm={handleConfirmUnlinkProject} + isLoading={usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.unlinkProject.apiServiceName, projectToBeUnlinked).isLoading} confirmBtnText='Unlink' - description='Are you sure you want to unlink this Project?' + description={`Are you sure you want to unlink ${projectToBeUnlinked?.projectName}?`} title='Confirm Project Unlink' /> } @@ -58,9 +97,7 @@ const ProjectList = () => { onProjectTitleClick={handleProjectTitleClick} projectDetails={item} key={item.projectID} - handleUnlinkProject={() => { - setShowConfirmationModal(true); - }} + handleUnlinkProject={handleUnlinkProject} /> )) : ( diff --git a/webapp/src/hooks/usePluginApi.ts b/webapp/src/hooks/usePluginApi.ts index 495aade3..8dd3a8b6 100644 --- a/webapp/src/hooks/usePluginApi.ts +++ b/webapp/src/hooks/usePluginApi.ts @@ -1,4 +1,5 @@ import {useSelector, useDispatch} from 'react-redux'; +import {AnyAction} from 'redux'; import services from 'services'; @@ -7,8 +8,8 @@ function usePluginApi() { 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/plugin_constants/index.ts b/webapp/src/plugin_constants/index.ts index fea0eb17..76c38c19 100644 --- a/webapp/src/plugin_constants/index.ts +++ b/webapp/src/plugin_constants/index.ts @@ -33,6 +33,11 @@ const pluginApiServiceConfigs: Record = { 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 15b264dd..e0014cc6 100644 --- a/webapp/src/reducers/linkModal/index.ts +++ b/webapp/src/reducers/linkModal/index.ts @@ -2,23 +2,18 @@ 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: CreateTaskModal, action: PayloadAction>) => { + 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: CreateTaskModal) => { + 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 585111ae..e2f3f898 100644 --- a/webapp/src/reducers/projectDetails/index.ts +++ b/webapp/src/reducers/projectDetails/index.ts @@ -1,6 +1,7 @@ import {createSlice, PayloadAction} from '@reduxjs/toolkit'; const initialState: ProjectDetails = { + mattermostID: '', projectID: '', projectName: '', organizationName: '', @@ -11,11 +12,13 @@ export const projectDetailsSlice = createSlice({ initialState, reducers: { setProjectDetails: (state: ProjectDetails, action: PayloadAction) => { + state.mattermostID = action.payload.mattermostID; state.projectID = action.payload.projectID; state.projectName = action.payload.projectName; state.organizationName = action.payload.organizationName; }, resetProjectDetails: (state: ProjectDetails) => { + state.mattermostID = ''; state.projectID = ''; state.projectName = ''; state.organizationName = ''; diff --git a/webapp/src/selectors/index.tsx b/webapp/src/selectors/index.tsx index 67e9f99c..1c30e192 100644 --- a/webapp/src/selectors/index.tsx +++ b/webapp/src/selectors/index.tsx @@ -2,10 +2,16 @@ import plugin_constants from 'plugin_constants'; const pluginPrefix = `plugins-${plugin_constants.pluginId}`; +// TODO: create a type for global state + export const getprojectDetailsState = (state: any) => { return state[pluginPrefix].projectDetailsSlice; }; +export const getLinkModalState = (state: any): LinkProjectModalState => { + return state[pluginPrefix].openLinkModalReducer; +}; + export const getRhsState = (state: any): {isSidebarOpen: boolean} => { return state.views.rhs; }; diff --git a/webapp/src/services/index.ts b/webapp/src/services/index.ts index 1219b173..f94f6a74 100644 --- a/webapp/src/services/index.ts +++ b/webapp/src/services/index.ts @@ -34,6 +34,14 @@ const pluginApi = createApi({ 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/types/common/index.d.ts b/webapp/src/types/common/index.d.ts index 139d6159..20498510 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' +type ApiServiceName = 'createTask' | 'testGet' | 'createLink' | 'getAllLinkedProjectsList' | 'unlinkProject' type PluginApiService = { path: string, @@ -38,7 +38,7 @@ type CreateTaskPayload = { fields: CreateTaskFields, } -type APIRequestPayload = CreateTaskPayload | LinkPayload | void; +type APIRequestPayload = CreateTaskPayload | LinkPayload | ProjectDetails | void; type DropdownOptionType = { label?: string | JSX.Element; @@ -51,6 +51,7 @@ type TabsData = { } type ProjectDetails = { + mattermostID: string projectID: string, projectName: string, organizationName: string 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, +} From 9637e820ab89b80fcb2a50b8d4bbc71ea9815ef3 Mon Sep 17 00:00:00 2001 From: Abhishek Verma Date: Fri, 12 Aug 2022 13:05:03 +0530 Subject: [PATCH 05/28] [MI-2001]: Review fixes --- server/constants/routes.go | 12 ++++++------ server/plugin/api.go | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/server/constants/routes.go b/server/constants/routes.go index fac6eec1..74ff53cb 100644 --- a/server/constants/routes.go +++ b/server/constants/routes.go @@ -2,10 +2,10 @@ package constants const ( // Plugin API Routes - APIPrefix = "/api/v1" - WildRoute = "{anything:.*}" - PathOAuthConnect = "/oauth/connect" - PathOAuthCallback = "/oauth/complete" - PathGetAllLinkedProjects = "/link/project" - PathUnlinkProject = "/unlink/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 ef500fca..481ed36d 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -36,7 +36,7 @@ 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) } @@ -46,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 } From 15cee426faf3fe914490c682347e6cfa549e6e91 Mon Sep 17 00:00:00 2001 From: Abhishek Verma Date: Fri, 12 Aug 2022 13:26:35 +0530 Subject: [PATCH 06/28] [MI-2002]: Created plugin API to fetch user details and UI integration with other changes --- server/constants/routes.go | 5 +- server/plugin/api.go | 32 +++++++++++ webapp/src/components/emptyState/index.tsx | 55 ++++++++++++++++--- webapp/src/components/emptyState/styles.scss | 4 -- .../containers/Rhs/accountNotLinked/index.tsx | 38 +++++++++++++ webapp/src/containers/Rhs/index.tsx | 35 ++++++++++-- .../src/containers/Rhs/projectList/index.tsx | 52 ++++++++++-------- webapp/src/hooks/index.js | 23 +++++++- webapp/src/plugin_constants/index.ts | 5 ++ webapp/src/services/index.ts | 7 +++ webapp/src/styles/_components.scss | 16 ++++++ webapp/src/styles/_utils.scss | 5 ++ webapp/src/types/common/index.d.ts | 8 ++- 13 files changed, 241 insertions(+), 44 deletions(-) create mode 100644 webapp/src/containers/Rhs/accountNotLinked/index.tsx 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 481ed36d..57ba6549 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. @@ -231,6 +232,37 @@ func (p *Plugin) handleUnlinkProject(w http.ResponseWriter, r *http.Request) { } } +// handleUnlinkProject unlinks a project +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.ErrorDecodingBody, "Error", err.Error()) + p.handleError(w, r, &serializers.Error{Code: http.StatusBadRequest, 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 marhsalling 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 43434621..d6faae83 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,20 +12,57 @@ type EmptyStatePropTypes = { }, buttonText?: string, buttonAction?: (event: React.SyntheticEvent) => void; + icon?: DisplayIcon; } -const EmptyState = ({title, subTitle, buttonText, buttonAction}: EmptyStatePropTypes) => { +// TODO: UI to be changed +const EmptyState = ({title, subTitle, buttonText, buttonAction, icon = 'folder'}: EmptyStatePropTypes) => { return (
- - - + { + icon === 'azure' && ( + + + + ) + } + { + icon === 'folder' && ( + + + + + ) + }

{title}

{subTitle && ( diff --git a/webapp/src/components/emptyState/styles.scss b/webapp/src/components/emptyState/styles.scss index c55d2588..54993b75 100644 --- a/webapp/src/components/emptyState/styles.scss +++ b/webapp/src/components/emptyState/styles.scss @@ -25,10 +25,6 @@ &__btn { margin-top: 24px; } - - svg { - fill: rgba(var(--center-channel-color-rgb), 0.5); - } } .slash-command { 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..d0c1c2fa 100644 --- a/webapp/src/containers/Rhs/index.tsx +++ b/webapp/src/containers/Rhs/index.tsx @@ -1,24 +1,49 @@ -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(); + // Fetch the connected account details when RHS is opened + useEffect(() => { + if (getRhsState(usePlugin.state).isSidebarOpen) { + usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName); + } + }, []); + if (!getRhsState(usePlugin.state).isSidebarOpen) { return <>; } return ( -
+
+ { + usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName).isLoading && + + } + { + usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName).isError && + + } { - getprojectDetailsState(usePlugin.state).projectID ? - : - + !usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName).isLoading && + !usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName).isError && + usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName).isSuccess && ( + getprojectDetailsState(usePlugin.state).projectID ? + : + + ) + }
); diff --git a/webapp/src/containers/Rhs/projectList/index.tsx b/webapp/src/containers/Rhs/projectList/index.tsx index 48e95089..48f6a8d0 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).data; + const data = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName).data as ProjectDetails[]; return ( <> @@ -88,27 +88,35 @@ const ProjectList = () => { ) } { - usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName).isSuccess && - data && - ( - data?.length > 0 ? - data?.map((item) => ( - - )) : - ( - - ) - ) + usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName).isSuccess && ( + data && data.length > 0 ? + <> + { + data?.map((item) => ( + + ), + ) + } +
+ +
+ : + ) } ); diff --git a/webapp/src/hooks/index.js b/webapp/src/hooks/index.js index 955cd39a..9c712531 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,21 @@ 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..1452ba59 100644 --- a/webapp/src/plugin_constants/index.ts +++ b/webapp/src/plugin_constants/index.ts @@ -38,6 +38,11 @@ const pluginApiServiceConfigs: Record = { 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..76031f7b 100644 --- a/webapp/src/styles/_components.scss +++ b/webapp/src/styles/_components.scss @@ -18,3 +18,19 @@ 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); + } +} 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 20498510..8715e743 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, @@ -38,7 +38,7 @@ type CreateTaskPayload = { fields: CreateTaskFields, } -type APIRequestPayload = CreateTaskPayload | LinkPayload | ProjectDetails | void; +type APIRequestPayload = CreateTaskPayload | LinkPayload | ProjectDetails | UserDetails | void; type DropdownOptionType = { label?: string | JSX.Element; @@ -57,6 +57,10 @@ type ProjectDetails = { organizationName: string } +type UserDetails = { + MattermostUserID: string +} + type eventType = 'create' | 'update' | 'delete' type SubscriptionDetails = { From 84397d0c0fcfeadf45872fa5ea7a261e19e6808e Mon Sep 17 00:00:00 2001 From: Abhishek Verma Date: Fri, 12 Aug 2022 15:47:23 +0530 Subject: [PATCH 07/28] [MI-2002]: Updated API paths --- webapp/src/plugin_constants/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/plugin_constants/index.ts b/webapp/src/plugin_constants/index.ts index 1452ba59..6bceb421 100644 --- a/webapp/src/plugin_constants/index.ts +++ b/webapp/src/plugin_constants/index.ts @@ -29,12 +29,12 @@ 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', }, From 5fb91555ba79224c9cd0da899e276eedaf65efd8 Mon Sep 17 00:00:00 2001 From: Abhishek Verma Date: Fri, 12 Aug 2022 20:56:53 +0530 Subject: [PATCH 08/28] [MI-2049]: Added websocket support to detect user connection details and a centralised check for root modals --- 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/containers/LinkModal/index.tsx | 17 ++++--- .../containers/Rhs/accountNotLinked/index.tsx | 12 ----- webapp/src/containers/Rhs/index.tsx | 28 ++---------- .../src/containers/Rhs/projectList/index.tsx | 6 +-- webapp/src/hooks/index.js | 40 ++++------------- webapp/src/hooks/usePluginApi.ts | 6 ++- webapp/src/index.tsx | 16 ++++--- webapp/src/reducers/globalModal/index.ts | 25 +++++++++++ webapp/src/reducers/index.ts | 8 +++- webapp/src/reducers/linkModal/index.ts | 26 +++++------ webapp/src/reducers/userConnected/index.ts | 23 ++++++++++ webapp/src/selectors/index.tsx | 6 ++- webapp/src/types/common/index.d.ts | 2 + webapp/src/types/common/store.d.ts | 10 +++++ webapp/src/types/mattermost-webapp/index.d.ts | 1 + webapp/src/utils/index.ts | 23 +++++----- webapp/src/websocket/index.ts | 16 +++++++ 23 files changed, 222 insertions(+), 122 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..13fec263 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 { + Connected 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..26a8a7da 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/containers/LinkModal/index.tsx b/webapp/src/containers/LinkModal/index.tsx index b7cfc245..b8c3a509 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 @@ -83,12 +83,15 @@ 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).isLoading; return ( { onHide={resetModalState} onConfirm={onConfirm} confirmBtnText='Link new project' - cancelDisabled={usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.createLink.apiServiceName, projectDetails).isLoading} - confirmDisabled={usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.createLink.apiServiceName, projectDetails).isLoading} - loading={usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.createLink.apiServiceName, projectDetails).isLoading} + cancelDisabled={isLoading} + confirmDisabled={isLoading} + loading={isLoading} > <> { - 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 d0c1c2fa..9f0fc14b 100644 --- a/webapp/src/containers/Rhs/index.tsx +++ b/webapp/src/containers/Rhs/index.tsx @@ -1,12 +1,8 @@ -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 AccountNotLinked from './accountNotLinked'; import ProjectList from './projectList'; import ProjectDetails from './projectDetails'; @@ -14,13 +10,6 @@ import ProjectDetails from './projectDetails'; const Rhs = (): JSX.Element => { const usePlugin = usePluginApi(); - // Fetch the connected account details when RHS is opened - useEffect(() => { - if (getRhsState(usePlugin.state).isSidebarOpen) { - usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName); - } - }, []); - if (!getRhsState(usePlugin.state).isSidebarOpen) { return <>; } @@ -28,22 +17,13 @@ const Rhs = (): JSX.Element => { return (
{ - usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName).isLoading && - + !usePlugin.isUserAccountConnected() && } { - usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName).isError && - - } - { - !usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName).isLoading && - !usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName).isError && - usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName).isSuccess && ( + usePlugin.isUserAccountConnected() && ( getprojectDetailsState(usePlugin.state).projectID ? : - - ) - + ) }
); diff --git a/webapp/src/containers/Rhs/projectList/index.tsx b/webapp/src/containers/Rhs/projectList/index.tsx index 48f6a8d0..c3ffa950 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 = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName).data as ProjectDetails[]; diff --git a/webapp/src/hooks/index.js b/webapp/src/hooks/index.js index 9c712531..8973127b 100644 --- a/webapp/src/hooks/index.js +++ b/webapp/src/hooks/index.js @@ -1,56 +1,32 @@ -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) { this.store = store; } - closeRhs() { - this.store.dispatch({ - type: 'UPDATE_RHS_STATE', - state: null, - }); - } - slashCommandWillBePostedHook = (message, contextArgs) => { let commandTrimmed; if (message) { 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)); - return Promise.resolve({}); - } - if (commandTrimmed && commandTrimmed.startsWith('/azuredevops link')) { - const args = splitArgs(commandTrimmed); - this.store.dispatch(showLinkModal(args)); + // TODO: refactor + // const args = splitArgs(commandTrimmed); 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 8dd3a8b6..b1932807 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..d4f82a58 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 globalModalSlice from './globalModal'; +import openLinkModalSlice from './linkModal'; import openTaskModalReducer from './taskModal'; import projectDetailsSlice from './projectDetails'; +import userConnectedSlice from './userConnected'; import testReducer from './testReducer'; const reducers = combineReducers({ - openLinkModalReducer, + globalModalSlice, + openLinkModalSlice, openTaskModalReducer, 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/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 1c30e192..60470a5a 100644 --- a/webapp/src/selectors/index.tsx +++ b/webapp/src/selectors/index.tsx @@ -4,12 +4,16 @@ const pluginPrefix = `plugins-${plugin_constants.pluginId}`; // TODO: create a type for global state +export const getGlobalModalState = (state: any): GlobalModalState => { + return state[pluginPrefix].globalModalSlice; +}; + export const getprojectDetailsState = (state: any) => { return state[pluginPrefix].projectDetailsSlice; }; export const getLinkModalState = (state: any): LinkProjectModalState => { - return state[pluginPrefix].openLinkModalReducer; + return state[pluginPrefix].openLinkModalSlice; }; export const getRhsState = (state: any): {isSidebarOpen: boolean} => { diff --git a/webapp/src/types/common/index.d.ts b/webapp/src/types/common/index.d.ts index 8715e743..364af174 100644 --- a/webapp/src/types/common/index.d.ts +++ b/webapp/src/types/common/index.d.ts @@ -68,3 +68,5 @@ type SubscriptionDetails = { name: string eventType: eventType } + +type ModalId = 'linkProject' | 'createBoardTask' | null diff --git a/webapp/src/types/common/store.d.ts b/webapp/src/types/common/store.d.ts index db390532..ccf44819 100644 --- a/webapp/src/types/common/store.d.ts +++ b/webapp/src/types/common/store.d.ts @@ -1,3 +1,13 @@ +type GlobalModalState = { + modalId: ModalId + commandArgs: Array +} + +type GlobalModalActionPayload = { + isVisible: boolean + commandArgs: Array +} + type LinkProjectModalState = { visibility: boolean, organization: string, diff --git a/webapp/src/types/mattermost-webapp/index.d.ts b/webapp/src/types/mattermost-webapp/index.d.ts index f94de6f8..13148061 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: any) => 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..3f3a9cec --- /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 (_: any) => { + store.dispatch(toggleIsConnected(true) as Action); + }; +} + +export function handleDisconnect(store: Store>>) { + return (_: any) => { + store.dispatch(toggleIsConnected(false) as Action); + }; +} From 268bc790f704b9c5f45379054e4b621b24cbb5e5 Mon Sep 17 00:00:00 2001 From: ayusht2810 Date: Tue, 16 Aug 2022 12:27:03 +0530 Subject: [PATCH 09/28] [MI-2010]: API to create subscriptions --- server/constants/constants.go | 12 +- server/constants/messages.go | 41 ++++--- server/constants/routes.go | 23 +++- server/constants/store.go | 7 +- server/plugin/api.go | 170 ++++++++++++++++++++++------ server/plugin/client.go | 69 +++++++---- server/plugin/utils.go | 43 ++++++- server/serializers/subscriptions.go | 107 +++++++++++++++++ server/store/subscriptions.go | 133 ++++++++++++++++++++++ server/store/utils.go | 8 ++ 10 files changed, 524 insertions(+), 89 deletions(-) create mode 100644 server/serializers/subscriptions.go create mode 100644 server/store/subscriptions.go diff --git a/server/constants/constants.go b/server/constants/constants.go index ab67b3f6..18ff21e9 100644 --- a/server/constants/constants.go +++ b/server/constants/constants.go @@ -18,11 +18,6 @@ const ( HelpText = "###### Mattermost Azure Devops Plugin - Slash Command Help\n" InvalidCommand = "Invalid command parameters. Please use `/azuredevops help` for more information." - // Azure API Routes - CreateTask = "/%s/%s/_apis/wit/workitems/$%s?api-version=7.1-preview.3" - GetTask = "%s/_apis/wit/workitems/%s?api-version=7.1-preview.3" - GetProject = "/%s/_apis/projects/%s?api-version=7.1-preview.4" - // Get task link preview constants HTTPS = "https:" HTTP = "http:" @@ -34,6 +29,13 @@ const ( CreateTaskAPIVersion = "7.1-preview.3" TasksIDAPIVersion = "5.1" TasksAPIVersion = "6.0" + // Subscription constants + PublisherID = "tfs" + ConsumerID = "webHooks" + ConsumerActionID = "httpRequest" + Create = "create" + Update = "update" + Delete = "delete" // Authorization constants Bearer = "Bearer" diff --git a/server/constants/messages.go b/server/constants/messages.go index 0f6a6a81..9863288d 100644 --- a/server/constants/messages.go +++ b/server/constants/messages.go @@ -3,29 +3,44 @@ package constants const ( // TODO: all these messages are to be verified from Mike at the end GenericErrorMessage = "Something went wrong, please try again later" + // Generic + ConnectAccount = "[Click here to link your Azure DevOps account](%s%s)" + ConnectAccountFirst = "You do not have any Azure Devops account connected. Kindly link the account first" + UserConnected = "Your Azure Devops account is succesfully connected!" + UserAlreadyConnected = "Your Azure Devops account is already connected" + UserDisconnected = "Your Azure Devops account is now disconnected" + CreatedTask = "Link for newly created task: %s" + TaskTitle = "[%s #%d: %s](%s)" + TaskPreviewMessage = "State: %s\nAssigned To: %s\nDescription: %s" + AlreadyLinkedProject = "This project is already linked." + NoProjectLinked = "No project is linked, please link a project." + + // Validations + OrganizationRequired = "organization is required" + ProjectRequired = "project is required" + TaskTypeRequired = "task type is required" + TaskTitleRequired = "task title is required" + EventTypeRequired = "event type is required" + ChannelNameRequired = "channel name is required" +) + +const ( + // Error messages Error = "error" NotAuthorized = "not authorized" - OrganizationRequired = "organization is required" - ProjectRequired = "project is required" - TaskTypeRequired = "task type is required" - TaskTitleRequired = "task title is required" - ConnectAccount = "[Click here to link your Azure DevOps account](%s%s)" - ConnectAccountFirst = "You do not have any Azure Devops account connected. Kindly link the account first" - UserConnected = "Your Azure Devops account is succesfully connected!" - UserAlreadyConnected = "Your Azure Devops account is already connected" - UserDisconnected = "Your Azure Devops account is now disconnected" UnableToDisconnectUser = "Unable to disconnect user" UnableToCheckIfAlreadyConnected = "Unable to check if user account is already connected" UnableToStoreOauthState = "Unable to store oAuth state for the userID %s" AuthAttemptExpired = "Authentication attempt expired, please try again" InvalidAuthState = "Invalid oauth state, please try again" - CreatedTask = "Link for newly created task: %s" - TaskTitle = "[%s #%d: %s](%s)" - TaskPreviewMessage = "**State:** %s\n**Assigned To:** %s\n**Description:** %s" - 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 exists" ErrorUnlinkProject = "Error in unlinking the project" + FetchSubscriptionListError = "Error in fetching subscription list" + CreateSubscriptionError = "Error in creating subscription" + ProjectNotLinked = "Requested project is not linked" + GetSubscriptionListError = "Error getting Subscription List" + SubscriptionAlreadyPresent = "Subscription is already present" ) diff --git a/server/constants/routes.go b/server/constants/routes.go index fbe21f1f..bf03e2dc 100644 --- a/server/constants/routes.go +++ b/server/constants/routes.go @@ -2,11 +2,22 @@ package constants const ( // Plugin API Routes - APIPrefix = "/api/v1" - WildRoute = "{anything:.*}" - PathOAuthConnect = "/oauth/connect" - PathOAuthCallback = "/oauth/complete" + APIPrefix = "/api/v1" + WildRoute = "{anything:.*}" + PathOAuthConnect = "/oauth/connect" + PathOAuthCallback = "/oauth/complete" PathLinkedProjects = "/project/link" - PathUnlinkProject = "/project/unlink" - PathUser = "/user" + PathGetAllLinkedProjects = "/project/link" + PathUnlinkProject = "/project/unlink" + PathUser = "/user" + PathCreateTasks = "/tasks" + PathLinkProject = "/link" + PathSubscriptions = "/subscriptions" + PathSubscriptionotifications = "/notification" + + // Azure API paths + CreateTask = "/%s/%s/_apis/wit/workitems/$%s?api-version=7.1-preview.3" + GetTask = "%s/_apis/wit/workitems/%s?api-version=7.1-preview.3" + GetProject = "/%s/_apis/projects/%s?api-version=7.1-preview.4" + CreateSubscription = "/%s/_apis/hooks/subscriptions?api-version=6.0" ) diff --git a/server/constants/store.go b/server/constants/store.go index e0b1e399..ee9bc999 100644 --- a/server/constants/store.go +++ b/server/constants/store.go @@ -8,7 +8,8 @@ const ( TTLSecondsForOAuthState int64 = 60 // KV store prefix keys - OAuthPrefix = "oAuth_%s" - ProjectKey = "%s_%s" - ProjectPrefix = "project_list" + OAuthPrefix = "oAuth_%s" + ProjectKey = "%s_%s" + ProjectPrefix = "project_list" + SubscriptionPrefix = "subscription_list" ) diff --git a/server/plugin/api.go b/server/plugin/api.go index 57ba6549..01b43c16 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -2,6 +2,7 @@ package plugin import ( "encoding/json" + "errors" "fmt" "net/http" "path/filepath" @@ -32,13 +33,14 @@ func (p *Plugin) InitRoutes() { // OAuth s.HandleFunc(constants.PathOAuthConnect, p.OAuthConnect).Methods(http.MethodGet) s.HandleFunc(constants.PathOAuthCallback, p.OAuthComplete).Methods(http.MethodGet) - - // Plugin APIs - s.HandleFunc("/tasks", p.handleAuthRequired(p.handleCreateTask)).Methods(http.MethodPost) - s.HandleFunc("/link", p.handleAuthRequired(p.handleLink)).Methods(http.MethodPost) + // Plugin APIs + s.HandleFunc(constants.PathCreateTasks, p.handleAuthRequired(p.checkOAuth(p.handleCreateTask))).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) + s.HandleFunc(constants.PathLinkProject, p.handleAuthRequired(p.checkOAuth(p.handleLink))).Methods(http.MethodPost) + s.HandleFunc(constants.PathGetAllLinkedProjects, p.handleAuthRequired(p.checkOAuth(p.handleGetAllLinkedProjects))).Methods(http.MethodGet) + s.HandleFunc(constants.PathUnlinkProject, p.handleAuthRequired(p.checkOAuth(p.handleUnlinkProject))).Methods(http.MethodPost) + s.HandleFunc(constants.PathUser, p.handleAuthRequired(p.checkOAuth(p.handleGetUserAccountDetails))).Methods(http.MethodGet) + s.HandleFunc(constants.PathSubscriptions, p.handleAuthRequired(p.checkOAuth(p.handleCreateSubscriptions))).Methods(http.MethodPost) } // API to create task of a project in an organization. @@ -103,14 +105,14 @@ func (p *Plugin) handleLink(w http.ResponseWriter, r *http.Request) { return } - if p.IsProjectLinked(projectList, serializers.ProjectDetails{OrganizationName: body.Organization, ProjectName: body.Project}) { + if _, isProjectLinked := p.IsProjectLinked(projectList, serializers.ProjectDetails{OrganizationName: body.Organization, ProjectName: body.Project}); isProjectLinked { p.DM(mattermostUserID, constants.AlreadyLinkedProject) return } - response, err := p.Client.Link(body, mattermostUserID) + response, statusCode, err := p.Client.Link(body, mattermostUserID) if err != nil { - p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + p.handleError(w, r, &serializers.Error{Code: statusCode, Message: err.Error()}) return } @@ -158,33 +160,6 @@ func (p *Plugin) handleGetAllLinkedProjects(w http.ResponseWriter, r *http.Reque } } -// handleAuthRequired verifies if the provided request is performed by an authorized source. -func (p *Plugin) handleAuthRequired(handleFunc http.HandlerFunc) http.HandlerFunc { - 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) - return - } - - handleFunc(w, r) - } -} - -func (p *Plugin) handleError(w http.ResponseWriter, r *http.Request, error *serializers.Error) { - w.Header().Add("Content-Type", "application/json") - w.WriteHeader(error.Code) - message := map[string]string{constants.Error: error.Message} - response, err := json.Marshal(message) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - if _, err := w.Write(response); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } -} - // handleUnlinkProject unlinks a project func (p *Plugin) handleUnlinkProject(w http.ResponseWriter, r *http.Request) { mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) @@ -204,7 +179,7 @@ func (p *Plugin) handleUnlinkProject(w http.ResponseWriter, r *http.Request) { return } - if !p.IsProjectLinked(projectList, *project) { + if _, isProjectLinked := p.IsProjectLinked(projectList, *project); !isProjectLinked { p.API.LogError(constants.ProjectNotFound, "Error") p.handleError(w, r, &serializers.Error{Code: http.StatusNotFound, Message: constants.ProjectNotFound}) return @@ -263,6 +238,127 @@ func (p *Plugin) handleGetUserAccountDetails(w http.ResponseWriter, r *http.Requ } } +func (p *Plugin) handleCreateSubscriptions(w http.ResponseWriter, r *http.Request) { + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + body, err := serializers.CreateSubscriptionRequestPayloadFromJSON(r.Body) + if err != nil { + p.API.LogError("Error in decoding the body for creating subscriptions", "Error", err.Error()) + p.handleError(w, r, &serializers.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + if err := body.IsSubscriptionRequestPayloadValid(); err != nil { + 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 + } + + project, isProjectLinked := p.IsProjectLinked(projectList, serializers.ProjectDetails{OrganizationName: body.Organization, ProjectName: body.Project}) + if !isProjectLinked { + p.API.LogError(constants.ProjectNotFound, "Error") + p.handleError(w, r, &serializers.Error{Code: http.StatusNotFound, Message: constants.ProjectNotLinked}) + return + } + + // TODO: remove later + teamID := "qteks46as3befxj4ec1mip5ume" + channel, channelErr := p.API.GetChannelByName(teamID, body.ChannelName, false) + if channelErr != nil { + p.handleError(w, r, &serializers.Error{Code: http.StatusBadRequest, Message: channelErr.DetailedError}) + return + } + + subscriptionList, err := p.Store.GetAllSubscriptions(mattermostUserID) + if err != nil { + p.API.LogError(constants.FetchSubscriptionListError, "Error", err.Error()) + p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + return + } + + if _, isSubscriptionPresent := p.IsSubscriptionPresent(subscriptionList, serializers.SubscriptionDetails{OrganizationName: body.Organization, ProjectName: body.Project, ChannelID: channel.Id, EventType: body.EventType}); isSubscriptionPresent { + p.API.LogError(constants.SubscriptionAlreadyPresent, "Error") + p.handleError(w, r, &serializers.Error{Code: http.StatusBadRequest, Message: constants.SubscriptionAlreadyPresent}) + return + } + + subscription, statusCode, err := p.Client.CreateSubscription(body, project, channel.Id, p.GetPluginURL(), mattermostUserID) + if err != nil { + p.API.LogError(constants.CreateSubscriptionError, "Error", err.Error()) + p.handleError(w, r, &serializers.Error{Code: statusCode, Message: err.Error()}) + return + } + + p.Store.StoreSubscription(&serializers.SubscriptionDetails{ + MattermostUserID: mattermostUserID, + ProjectName: body.Project, + OrganizationName: body.Organization, + EventType: body.EventType, + ChannelID: channel.Id, + }) + + response, err := json.Marshal(subscription) + if err != nil { + p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + return + } + + w.Header().Add("Content-Type", "application/json") + if _, err = w.Write(response); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + + +func (p *Plugin) checkOAuth(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + user, err := p.Store.LoadUser(mattermostUserID) + if err != nil || user.AccessToken == "" { + if errors.Is(err, ErrNotFound) || user.AccessToken == "" { + p.handleError(w, r, &serializers.Error{Code: http.StatusUnauthorized, Message: constants.ConnectAccountFirst}) + } else { + p.API.LogError("Unable to get user", "Error", err.Error()) + p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: constants.GenericErrorMessage}) + } + return + } + handler(w, r) + } +} + +// handleAuthRequired verifies if the provided request is performed by an authorized source. +func (p *Plugin) handleAuthRequired(handleFunc http.HandlerFunc) http.HandlerFunc { + 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) + return + } + + handleFunc(w, r) + } +} + +func (p *Plugin) handleError(w http.ResponseWriter, r *http.Request, error *serializers.Error) { + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(error.Code) + message := map[string]string{constants.Error: error.Message} + response, err := json.Marshal(message) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + 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/plugin/client.go b/server/plugin/client.go index 86b1615f..eea4da14 100644 --- a/server/plugin/client.go +++ b/server/plugin/client.go @@ -19,7 +19,8 @@ type Client interface { GenerateOAuthToken(encodedFormValues url.Values) (*serializers.OAuthSuccessResponse, int, error) CreateTask(body *serializers.CreateTaskRequestPayload, mattermostUserID string) (*serializers.TaskValue, int, error) GetTask(organization, taskID, mattermostUserID string) (*serializers.TaskValue, int, error) - Link(body *serializers.LinkRequestPayload, mattermostUserID string) (*serializers.Project, error) + Link(body *serializers.LinkRequestPayload, mattermostUserID string) (*serializers.Project, int, error) + CreateSubscription(body *serializers.CreateSubscriptionRequestPayload, project *serializers.ProjectDetails, channelID, pluginURL, mattermostUserID string) (*serializers.SubscriptionValue, int, error) } type client struct { @@ -75,17 +76,6 @@ func (c *client) CreateTask(body *serializers.CreateTaskRequestPayload, mattermo return task, statusCode, nil } -// Function to link a project and an organization. -func (c *client) Link(body *serializers.LinkRequestPayload, mattermostUserID string) (*serializers.Project, error) { - projectURL := fmt.Sprintf(constants.GetProject, body.Organization, body.Project) - var project *serializers.Project - if _, _, err := c.callJSON(c.plugin.getConfiguration().AzureDevopsAPIBaseURL, projectURL, http.MethodGet, mattermostUserID, nil, &project, nil); err != nil { - return nil, errors.Wrap(err, "failed to link Project") - } - - return project, nil -} - // Function to get the task. func (c *client) GetTask(organization, taskID, mattermostUserID string) (*serializers.TaskValue, int, error) { taskURL := fmt.Sprintf(constants.GetTask, organization, taskID) @@ -99,16 +89,17 @@ func (c *client) GetTask(organization, taskID, mattermostUserID string) (*serial return task, statusCode, nil } -// TODO: Uncomment the code later when needed. -// Wrapper to make REST API requests with "application/json" type content -// func (c *client) callJSON(url, path, method, mattermostUserID string, in, out interface{}, formValues url.Values) (responseData []byte, err error) { -// contentType := "application/json" -// buf := &bytes.Buffer{} -// if err = json.NewEncoder(buf).Encode(in); err != nil { -// return nil, err -// } -// return c.call(url, method, path, contentType, mattermostUserID, buf, out, formValues) -// } +// Function to link a project and an organization. +func (c *client) Link(body *serializers.LinkRequestPayload, mattermostUserID string) (*serializers.Project, int, error) { + projectURL := fmt.Sprintf(constants.GetProject, body.Organization, body.Project) + var project *serializers.Project + + _, statusCode, err := c.callJSON(c.plugin.getConfiguration().AzureDevopsAPIBaseURL, projectURL, http.MethodGet, mattermostUserID, nil, &project, nil) + if err != nil { + return nil, statusCode, errors.Wrap(err, "failed to link Project") + } + return project, statusCode, nil +} // Wrapper to make REST API requests with "application/x-www-form-urlencoded" type content func (c *client) callFormURLEncoded(url, path, method string, out interface{}, formValues url.Values) (responseData []byte, statusCode int, err error) { @@ -116,6 +107,40 @@ func (c *client) callFormURLEncoded(url, path, method string, out interface{}, f return c.call(url, method, path, contentType, "", nil, out, formValues) } +func (c *client) CreateSubscription(body *serializers.CreateSubscriptionRequestPayload, project *serializers.ProjectDetails, channelID, pluginURL, mattermostUserID string) (*serializers.SubscriptionValue, int, error) { + subscriptionURL := fmt.Sprintf(constants.CreateSubscription, body.Organization) + + publisherInputs := serializers.PublisherInputs{ + ProjectID: project.ProjectID, + } + + consumerInputs := serializers.ConsumerInputs{ + URL: fmt.Sprintf("%s%s?channelID=%s", strings.TrimRight(pluginURL, "/"), constants.PathSubscriptionotifications, channelID), + } + + statusData := map[string]string{ + constants.Create: "workitem.created", + constants.Update: "workitem.updated", + constants.Delete: "workitem.deleted", + } + + payload := serializers.CreateSubscriptionBodyPayload{ + PublisherID: constants.PublisherID, + EventType: statusData[body.EventType], + ConsumerId: constants.ConsumerID, + ConsumerActionId: constants.ConsumerActionID, + PublisherInputs: publisherInputs, + ConsumerInputs: consumerInputs, + } + var subscription *serializers.SubscriptionValue + _, statusCode, err := c.callJSON(c.plugin.getConfiguration().AzureDevopsAPIBaseURL, subscriptionURL, http.MethodPost, mattermostUserID, payload, &subscription, nil) + if err != nil { + return nil, statusCode, errors.Wrap(err, "failed to create subscription") + } + + return subscription, statusCode, nil +} + // Wrapper to make REST API requests with "application/json-patch+json" type content func (c *client) callPatchJSON(url, path, method, mattermostUserID string, in, out interface{}, formValues url.Values) (responseData []byte, statusCode int, err error) { contentType := "application/json-patch+json" diff --git a/server/plugin/utils.go b/server/plugin/utils.go index 1d5fd424..9e57aa96 100644 --- a/server/plugin/utils.go +++ b/server/plugin/utils.go @@ -161,11 +161,48 @@ func (p *Plugin) AddAuthorization(r *http.Request, mattermostUserID string) erro return nil } -func (p *Plugin) IsProjectLinked(projectList []serializers.ProjectDetails, project serializers.ProjectDetails) bool { +func (p *Plugin) AddBasicAuthorization(r *http.Request, mattermostUserID string) error { + user, err := p.Store.LoadUser(mattermostUserID) + if err != nil { + return err + } + + token, err := p.ParseAuthToken(user.AccessToken) + if err != nil { + return err + } + + r.SetBasicAuth(mattermostUserID, token) + return nil +} + +func (p *Plugin) IsProjectLinked(projectList []serializers.ProjectDetails, project serializers.ProjectDetails) (*serializers.ProjectDetails, bool) { for _, a := range projectList { if a.ProjectName == project.ProjectName && a.OrganizationName == project.OrganizationName { - return true + return &a, true + } + } + return nil, false +} + +func (p *Plugin) IsSubscriptionPresent(subscriptionList []serializers.SubscriptionDetails, subscription serializers.SubscriptionDetails) (*serializers.SubscriptionDetails, bool) { + for _, a := range subscriptionList { + if a.ProjectName == subscription.ProjectName && a.OrganizationName == subscription.OrganizationName && a.ChannelID == subscription.ChannelID && a.EventType == subscription.EventType { + return &a, true } } - return false + return nil, false +} + +func (p *Plugin) IsAnyProjectLinked(mattermostUserID string) (bool, error) { + projectList, err := p.Store.GetAllProjects(mattermostUserID) + if err != nil { + return false, err + } + + if len(projectList) == 0 { + return false, nil + } + + return true, nil } diff --git a/server/serializers/subscriptions.go b/server/serializers/subscriptions.go new file mode 100644 index 00000000..fc6e95cf --- /dev/null +++ b/server/serializers/subscriptions.go @@ -0,0 +1,107 @@ +package serializers + +import ( + "encoding/json" + "errors" + "io" + + "github.com/Brightscout/mattermost-plugin-azure-devops/server/constants" +) + +type UserID struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + UniqueName string `json:"uniqueName"` +} + +type PublisherInputs struct { + ProjectID string `json:"projectId"` +} + +type ConsumerInputs struct { + URL string `json:"url"` +} + +type SubscriptionValue struct { + ID string `json:"id"` + URL string `json:"url"` + EventType string `json:"eventType"` + ConsumerID string `json:"consumerId"` + ConsumerActionID string `json:"consumerActionId"` + CreatedBy UserID `json:"createdBy"` + ModifiedBy UserID `json:"modifiedBy"` + PublisherInputs PublisherInputs `json:"publisherInputs"` + ConsumerInputs ConsumerInputs `json:"consumerInputs"` +} + +type SubscriptionList struct { + Count int `json:"count"` + SubscriptionValue []SubscriptionValue `json:"value"` +} + +type SubscriptionListRequestPayload struct { + Organization string `json:"organization"` +} + +type CreateSubscriptionRequestPayload struct { + Organization string `json:"organization"` + Project string `json:"project"` + EventType string `json:"eventType"` + ChannelName string `json:"channelName"` +} + +type CreateSubscriptionBodyPayload struct { + PublisherID string `json:"publisherId"` + EventType string `json:"eventType"` + ConsumerId string `json:"consumerId"` + ConsumerActionId string `json:"consumerActionId"` + PublisherInputs PublisherInputs `json:"publisherInputs"` + ConsumerInputs ConsumerInputs `json:"consumerInputs"` +} + +type SubscriptionDetails struct { + MattermostUserID string `json:"mattermostUserID"` + ProjectName string `json:"projectName"` + OrganizationName string `json:"organizationName"` + EventType string `json:"eventType"` + ChannelID string `json:"channelID"` +} + +func SubscriptionListRequestPayloadFromJSON(data io.Reader) (*SubscriptionListRequestPayload, error) { + var body *SubscriptionListRequestPayload + if err := json.NewDecoder(data).Decode(&body); err != nil { + return nil, err + } + return body, nil +} + +func CreateSubscriptionRequestPayloadFromJSON(data io.Reader) (*CreateSubscriptionRequestPayload, error) { + var body *CreateSubscriptionRequestPayload + if err := json.NewDecoder(data).Decode(&body); err != nil { + return nil, err + } + return body, nil +} + +func (t *SubscriptionListRequestPayload) IsSubscriptionRequestPayloadValid() error { + if t.Organization == "" { + return errors.New(constants.OrganizationRequired) + } + return nil +} + +func (t *CreateSubscriptionRequestPayload) IsSubscriptionRequestPayloadValid() error { + if t.Organization == "" { + return errors.New(constants.OrganizationRequired) + } + if t.Project == "" { + return errors.New(constants.ProjectRequired) + } + if t.EventType == "" { + return errors.New(constants.EventTypeRequired) + } + if t.ChannelName == "" { + return errors.New(constants.ChannelNameRequired) + } + return nil +} diff --git a/server/store/subscriptions.go b/server/store/subscriptions.go new file mode 100644 index 00000000..82ba4f76 --- /dev/null +++ b/server/store/subscriptions.go @@ -0,0 +1,133 @@ +package store + +import ( + "encoding/json" + + "github.com/pkg/errors" + + "github.com/Brightscout/mattermost-plugin-azure-devops/server/constants" + "github.com/Brightscout/mattermost-plugin-azure-devops/server/serializers" +) + +type SubscriptionListMap map[string]serializers.SubscriptionDetails + +type SubscriptionList struct { + ByMattermostUserID map[string]SubscriptionListMap +} + +func NewSubscriptionList() *SubscriptionList { + return &SubscriptionList{ + ByMattermostUserID: map[string]SubscriptionListMap{}, + } +} + +func (s *Store) StoreSubscription(subscription *serializers.SubscriptionDetails) error { + key := GetSubscriptionListMapKey() + if err := s.AtomicModify(key, func(initialBytes []byte) ([]byte, error) { + subscriptionList, err := SubscriptionListFromJSON(initialBytes) + if err != nil { + return nil, err + } + + subscriptionList.AddSubscription(subscription.MattermostUserID, subscription) + modifiedBytes, marshalErr := json.Marshal(subscriptionList) + if marshalErr != nil { + return nil, marshalErr + } + return modifiedBytes, nil + }); err != nil { + return err + } + + return nil +} + +func (subscriptionList *SubscriptionList) AddSubscription(userID string, subscription *serializers.SubscriptionDetails) { + if _, valid := subscriptionList.ByMattermostUserID[userID]; !valid { + subscriptionList.ByMattermostUserID[userID] = make(SubscriptionListMap) + } + + subscriptionKey := GetSubscriptionKey(userID, subscription.ProjectName, subscription.ChannelID, subscription.EventType) + subscriptionListValue := serializers.SubscriptionDetails{ + MattermostUserID: userID, + ProjectName: subscription.ProjectName, + OrganizationName: subscription.OrganizationName, + ChannelID: subscription.ChannelID, + EventType: subscription.EventType, + } + subscriptionList.ByMattermostUserID[userID][subscriptionKey] = subscriptionListValue +} + +func (s *Store) GetSubscription() (*SubscriptionList, error) { + key := GetSubscriptionListMapKey() + initialBytes, appErr := s.Load(key) + if appErr != nil { + return nil, errors.New(constants.GetSubscriptionListError) + } + + subscriptions, err := SubscriptionListFromJSON(initialBytes) + if err != nil { + return nil, errors.New(constants.GetSubscriptionListError) + } + + return subscriptions, nil +} + +func (s *Store) GetAllSubscriptions(userID string) ([]serializers.SubscriptionDetails, error) { + subscriptions, err := s.GetSubscription() + if err != nil { + return nil, err + } + + var subscriptionList []serializers.SubscriptionDetails + for _, subscription := range subscriptions.ByMattermostUserID[userID] { + subscriptionList = append(subscriptionList, subscription) + } + + return subscriptionList, nil +} + +// TODO: remove later if not needed. +func (s *Store) DeleteSubscription(subscription *serializers.SubscriptionDetails) error { + key := GetSubscriptionListMapKey() + if err := s.AtomicModify(key, func(initialBytes []byte) ([]byte, error) { + subscriptionList, err := SubscriptionListFromJSON(initialBytes) + if err != nil { + return nil, err + } + + subscriptionKey := GetSubscriptionKey(subscription.MattermostUserID, subscription.ProjectName, subscription.ChannelID, subscription.EventType) + subscriptionList.DeleteSubscriptionByKey(subscription.MattermostUserID, subscriptionKey) + modifiedBytes, marshalErr := json.Marshal(subscriptionList) + if marshalErr != nil { + return nil, marshalErr + } + return modifiedBytes, nil + }); err != nil { + return err + } + + return nil +} + +// TODO: remove later if not needed. +func (subscriptionList *SubscriptionList) DeleteSubscriptionByKey(userID, subscriptionKey string) { + for key := range subscriptionList.ByMattermostUserID[userID] { + if key == subscriptionKey { + delete(subscriptionList.ByMattermostUserID[userID], key) + } + } +} + +func SubscriptionListFromJSON(bytes []byte) (*SubscriptionList, error) { + var subscriptionList *SubscriptionList + if len(bytes) != 0 { + unmarshalErr := json.Unmarshal(bytes, &subscriptionList) + if unmarshalErr != nil { + return nil, unmarshalErr + } + } else { + subscriptionList = NewSubscriptionList() + } + return subscriptionList, nil +} diff --git a/server/store/utils.go b/server/store/utils.go index 7e5a67b6..6117979c 100644 --- a/server/store/utils.go +++ b/server/store/utils.go @@ -131,6 +131,14 @@ func GetOAuthKey(mattermostUserID string) string { return fmt.Sprintf(constants.OAuthPrefix, mattermostUserID) } +func GetSubscriptionListMapKey() string { + return GetKeyHash(constants.SubscriptionPrefix) +} + +func GetSubscriptionKey(mattermostUserID, projectID, channelID, eventType string) string { + return fmt.Sprintf("%s_%s_%s_%s", mattermostUserID, projectID, channelID, eventType) +} + // GetKeyHash can be used to create a hash from a string func GetKeyHash(key string) string { hash := sha256.New() From bb12691e5625945f4a592bf979490d808126f6b5 Mon Sep 17 00:00:00 2001 From: ayusht2810 Date: Tue, 16 Aug 2022 12:36:31 +0530 Subject: [PATCH 10/28] [MI-2010] Fix lint errors --- server/constants/constants.go | 1 + server/constants/messages.go | 2 +- server/constants/routes.go | 2 +- server/plugin/api.go | 3 +-- server/plugin/utils.go | 15 --------------- server/serializers/subscriptions.go | 19 ------------------- server/store/subscriptions.go | 2 -- 7 files changed, 4 insertions(+), 40 deletions(-) diff --git a/server/constants/constants.go b/server/constants/constants.go index 18ff21e9..f89034a6 100644 --- a/server/constants/constants.go +++ b/server/constants/constants.go @@ -29,6 +29,7 @@ const ( CreateTaskAPIVersion = "7.1-preview.3" TasksIDAPIVersion = "5.1" TasksAPIVersion = "6.0" + // Subscription constants PublisherID = "tfs" ConsumerID = "webHooks" diff --git a/server/constants/messages.go b/server/constants/messages.go index 9863288d..734f212a 100644 --- a/server/constants/messages.go +++ b/server/constants/messages.go @@ -2,8 +2,8 @@ package constants const ( // TODO: all these messages are to be verified from Mike at the end - GenericErrorMessage = "Something went wrong, please try again later" // Generic + GenericErrorMessage = "Something went wrong, please try again later" ConnectAccount = "[Click here to link your Azure DevOps account](%s%s)" ConnectAccountFirst = "You do not have any Azure Devops account connected. Kindly link the account first" UserConnected = "Your Azure Devops account is succesfully connected!" diff --git a/server/constants/routes.go b/server/constants/routes.go index bf03e2dc..505588ec 100644 --- a/server/constants/routes.go +++ b/server/constants/routes.go @@ -6,7 +6,7 @@ const ( WildRoute = "{anything:.*}" PathOAuthConnect = "/oauth/connect" PathOAuthCallback = "/oauth/complete" - PathLinkedProjects = "/project/link" + PathLinkedProjects = "/project/link" PathGetAllLinkedProjects = "/project/link" PathUnlinkProject = "/project/unlink" PathUser = "/user" diff --git a/server/plugin/api.go b/server/plugin/api.go index 01b43c16..d6ce7068 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -33,7 +33,7 @@ func (p *Plugin) InitRoutes() { // OAuth s.HandleFunc(constants.PathOAuthConnect, p.OAuthConnect).Methods(http.MethodGet) s.HandleFunc(constants.PathOAuthCallback, p.OAuthComplete).Methods(http.MethodGet) - // Plugin APIs + // Plugin APIs s.HandleFunc(constants.PathCreateTasks, p.handleAuthRequired(p.checkOAuth(p.handleCreateTask))).Methods(http.MethodPost) s.HandleFunc(constants.PathLinkedProjects, p.handleAuthRequired(p.handleGetAllLinkedProjects)).Methods(http.MethodGet) s.HandleFunc(constants.PathLinkProject, p.handleAuthRequired(p.checkOAuth(p.handleLink))).Methods(http.MethodPost) @@ -314,7 +314,6 @@ func (p *Plugin) handleCreateSubscriptions(w http.ResponseWriter, r *http.Reques } } - func (p *Plugin) checkOAuth(handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) diff --git a/server/plugin/utils.go b/server/plugin/utils.go index 9e57aa96..ed135da5 100644 --- a/server/plugin/utils.go +++ b/server/plugin/utils.go @@ -161,21 +161,6 @@ func (p *Plugin) AddAuthorization(r *http.Request, mattermostUserID string) erro return nil } -func (p *Plugin) AddBasicAuthorization(r *http.Request, mattermostUserID string) error { - user, err := p.Store.LoadUser(mattermostUserID) - if err != nil { - return err - } - - token, err := p.ParseAuthToken(user.AccessToken) - if err != nil { - return err - } - - r.SetBasicAuth(mattermostUserID, token) - return nil -} - func (p *Plugin) IsProjectLinked(projectList []serializers.ProjectDetails, project serializers.ProjectDetails) (*serializers.ProjectDetails, bool) { for _, a := range projectList { if a.ProjectName == project.ProjectName && a.OrganizationName == project.OrganizationName { diff --git a/server/serializers/subscriptions.go b/server/serializers/subscriptions.go index fc6e95cf..ece07295 100644 --- a/server/serializers/subscriptions.go +++ b/server/serializers/subscriptions.go @@ -39,10 +39,6 @@ type SubscriptionList struct { SubscriptionValue []SubscriptionValue `json:"value"` } -type SubscriptionListRequestPayload struct { - Organization string `json:"organization"` -} - type CreateSubscriptionRequestPayload struct { Organization string `json:"organization"` Project string `json:"project"` @@ -67,14 +63,6 @@ type SubscriptionDetails struct { ChannelID string `json:"channelID"` } -func SubscriptionListRequestPayloadFromJSON(data io.Reader) (*SubscriptionListRequestPayload, error) { - var body *SubscriptionListRequestPayload - if err := json.NewDecoder(data).Decode(&body); err != nil { - return nil, err - } - return body, nil -} - func CreateSubscriptionRequestPayloadFromJSON(data io.Reader) (*CreateSubscriptionRequestPayload, error) { var body *CreateSubscriptionRequestPayload if err := json.NewDecoder(data).Decode(&body); err != nil { @@ -83,13 +71,6 @@ func CreateSubscriptionRequestPayloadFromJSON(data io.Reader) (*CreateSubscripti return body, nil } -func (t *SubscriptionListRequestPayload) IsSubscriptionRequestPayloadValid() error { - if t.Organization == "" { - return errors.New(constants.OrganizationRequired) - } - return nil -} - func (t *CreateSubscriptionRequestPayload) IsSubscriptionRequestPayloadValid() error { if t.Organization == "" { return errors.New(constants.OrganizationRequired) diff --git a/server/store/subscriptions.go b/server/store/subscriptions.go index 82ba4f76..2067b066 100644 --- a/server/store/subscriptions.go +++ b/server/store/subscriptions.go @@ -87,7 +87,6 @@ func (s *Store) GetAllSubscriptions(userID string) ([]serializers.SubscriptionDe return subscriptionList, nil } -// TODO: remove later if not needed. func (s *Store) DeleteSubscription(subscription *serializers.SubscriptionDetails) error { key := GetSubscriptionListMapKey() if err := s.AtomicModify(key, func(initialBytes []byte) ([]byte, error) { @@ -110,7 +109,6 @@ func (s *Store) DeleteSubscription(subscription *serializers.SubscriptionDetails return nil } -// TODO: remove later if not needed. func (subscriptionList *SubscriptionList) DeleteSubscriptionByKey(userID, subscriptionKey string) { for key := range subscriptionList.ByMattermostUserID[userID] { if key == subscriptionKey { From 283e1c2daee6b4d979305045143d0de9ee40bca5 Mon Sep 17 00:00:00 2001 From: Abhishek Verma Date: Tue, 16 Aug 2022 12:45:47 +0530 Subject: [PATCH 11/28] [MI-2035]: Integrated unlinking project from details page --- .../components/buttons/iconButton/index.tsx | 6 +++ webapp/src/containers/Rhs/index.tsx | 2 +- .../containers/Rhs/projectDetails/index.tsx | 48 ++++++++++++++++--- .../src/containers/Rhs/projectList/index.tsx | 2 +- webapp/src/reducers/projectDetails/index.ts | 6 +-- webapp/src/selectors/index.tsx | 2 +- webapp/src/types/common/index.d.ts | 2 +- 7 files changed, 54 insertions(+), 14 deletions(-) diff --git a/webapp/src/components/buttons/iconButton/index.tsx b/webapp/src/components/buttons/iconButton/index.tsx index f1272702..b11e1308 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' @@ -22,6 +24,10 @@ const IconButton = ({tooltipText, iconClassName, extraClass = '', iconColor, onC variant='outline-danger' className={`plugin-btn button-wrapper btn-icon ${extraClass}`} onClick={onClick} + aria-label={tooltipText} + role='button' + tabIndex={0} + onKeyDown={() => onPressingEnterKey(event, () => onClick?.())} > { { usePlugin.isUserAccountConnected() && ( getprojectDetailsState(usePlugin.state).projectID ? - : + : ) }
diff --git a/webapp/src/containers/Rhs/projectDetails/index.tsx b/webapp/src/containers/Rhs/projectDetails/index.tsx index cc1eda0a..4c864e64 100644 --- a/webapp/src/containers/Rhs/projectDetails/index.tsx +++ b/webapp/src/containers/Rhs/projectDetails/index.tsx @@ -1,12 +1,16 @@ -import React, {useEffect} from 'react'; +import React, {useEffect, useState} from 'react'; import {useDispatch} from 'react-redux'; import SubscriptionCard from 'components/card/subscription'; import IconButton from 'components/buttons/iconButton'; import BackButton from 'components/buttons/backButton'; +import ConfirmationModal from 'components/modal/confirmationModal'; +import usePluginApi from 'hooks/usePluginApi'; import {resetProjectDetails} from 'reducers/projectDetails'; +import plugin_constants from 'plugin_constants'; + // TODO: dummy data, remove later const data: SubscriptionDetails[] = [ { @@ -26,31 +30,61 @@ const data: SubscriptionDetails[] = [ }, ]; -type ProjectDetailsProps = { - title: string -} +const ProjectDetails = (projectDetails: ProjectDetails) => { + // State variables + const [showConfirmationModal, setShowConfirmationModal] = useState(false); -const ProjectDetails = ({title}: ProjectDetailsProps) => { + // Hooks const dispatch = useDispatch(); + const usePlugin = usePluginApi(); const handleResetProjectDetails = () => { dispatch(resetProjectDetails()); }; + /** + * Opens a confirmation modal to confirm unlinking a project + */ + const handleUnlinkProject = () => { + setShowConfirmationModal(true); + }; + + // Handles unlinking a project + const handleConfirmUnlinkProject = async () => { + const unlinkProjectStatus = await usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.unlinkProject.apiServiceName, projectDetails); + + if (unlinkProjectStatus) { + handleResetProjectDetails(); + setShowConfirmationModal(false); + } + }; + // Reset the state when the component is unmounted useEffect(() => { - return handleResetProjectDetails(); + return () => { + handleResetProjectDetails(); + }; }, []); return ( <> + setShowConfirmationModal(false)} + onConfirm={handleConfirmUnlinkProject} + isLoading={usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.unlinkProject.apiServiceName, projectDetails).isLoading} + confirmBtnText='Unlink' + description={`Are you sure you want to unlink ${projectDetails?.projectName}?`} + title='Confirm Project Unlink' + />
-

{title}

+

{projectDetails.projectName}

handleUnlinkProject()} />
diff --git a/webapp/src/containers/Rhs/projectList/index.tsx b/webapp/src/containers/Rhs/projectList/index.tsx index c3ffa950..33708fec 100644 --- a/webapp/src/containers/Rhs/projectList/index.tsx +++ b/webapp/src/containers/Rhs/projectList/index.tsx @@ -92,7 +92,7 @@ const ProjectList = () => { data && data.length > 0 ? <> { - data?.map((item) => ( + data?.map((item: ProjectDetails) => ( ) => { - state.mattermostID = action.payload.mattermostID; + state.mattermostUserID = action.payload.mattermostUserID; state.projectID = action.payload.projectID; state.projectName = action.payload.projectName; state.organizationName = action.payload.organizationName; }, resetProjectDetails: (state: ProjectDetails) => { - state.mattermostID = ''; + state.mattermostUserID = ''; state.projectID = ''; state.projectName = ''; state.organizationName = ''; diff --git a/webapp/src/selectors/index.tsx b/webapp/src/selectors/index.tsx index 60470a5a..c1c91fb5 100644 --- a/webapp/src/selectors/index.tsx +++ b/webapp/src/selectors/index.tsx @@ -8,7 +8,7 @@ export const getGlobalModalState = (state: any): GlobalModalState => { return state[pluginPrefix].globalModalSlice; }; -export const getprojectDetailsState = (state: any) => { +export const getprojectDetailsState = (state: any): ProjectDetails => { return state[pluginPrefix].projectDetailsSlice; }; diff --git a/webapp/src/types/common/index.d.ts b/webapp/src/types/common/index.d.ts index 364af174..3fce7ed8 100644 --- a/webapp/src/types/common/index.d.ts +++ b/webapp/src/types/common/index.d.ts @@ -51,7 +51,7 @@ type TabsData = { } type ProjectDetails = { - mattermostID: string + mattermostUserID: string projectID: string, projectName: string, organizationName: string From f7ff1b65e3059ca02afdc6fc92e1d5bfb454eedb Mon Sep 17 00:00:00 2001 From: Abhishek Verma Date: Tue, 16 Aug 2022 12:56:43 +0530 Subject: [PATCH 12/28] [MI-1939]: Added refresh token logic --- server/constants/oauth_config.go | 1 + server/plugin/client.go | 6 ++++++ server/plugin/oAuth.go | 36 ++++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/server/constants/oauth_config.go b/server/constants/oauth_config.go index a52b1c4c..2191ca36 100644 --- a/server/constants/oauth_config.go +++ b/server/constants/oauth_config.go @@ -5,6 +5,7 @@ const ( Scopes = "vso.code vso.work_full" ClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" GrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" + GrantTypeRefresh = "refresh_token" // URL BaseOauthURL = "https://app.vssps.visualstudio.com" diff --git a/server/plugin/client.go b/server/plugin/client.go index 86b1615f..75f5181a 100644 --- a/server/plugin/client.go +++ b/server/plugin/client.go @@ -195,6 +195,12 @@ func (c *client) call(basePath, method, path, contentType string, mattermostUser } switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusNonAuthoritativeInfo: + if err := c.plugin.RefreshOAuthToken(mattermostUserID); err != nil { + return nil, err + } + _, err := c.call(basePath, method, path, contentType, mattermostUserID, inBody, out, formValues) + return nil, err case http.StatusOK, http.StatusCreated: if out != nil { if err = json.Unmarshal(responseData, out); err != nil { diff --git a/server/plugin/oAuth.go b/server/plugin/oAuth.go index df287538..9fec3e1a 100644 --- a/server/plugin/oAuth.go +++ b/server/plugin/oAuth.go @@ -131,6 +131,42 @@ func (p *Plugin) GenerateOAuthToken(code, state string) error { "redirect_uri": {fmt.Sprintf("%s%s%s", p.GetSiteURL(), p.GetPluginURLPath(), constants.PathOAuthCallback)}, } + return p.StoreOAuthToken(mattermostUserID, oauthTokenFormValues) +} + +// RefreshOAuthToken refreshes OAuth token +func (p *Plugin) RefreshOAuthToken(mattermostUserID string) error { + user, err := p.Store.LoadUser(mattermostUserID) + if err != nil { + p.DM(mattermostUserID, constants.GenericErrorMessage) + return errors.Wrap(err, err.Error()) + } + + decodedRefreshToken, err := p.decode(user.RefreshToken) + if err != nil { + p.DM(mattermostUserID, constants.GenericErrorMessage) + return errors.Wrap(err, err.Error()) + } + + decryptedRefreshToken, err := p.decrypt(decodedRefreshToken, []byte(p.getConfiguration().EncryptionSecret)) + if err != nil { + p.DM(mattermostUserID, constants.GenericErrorMessage) + return errors.Wrap(err, err.Error()) + } + + oauthTokenFormValues := url.Values{ + "client_assertion_type": {constants.ClientAssertionType}, + "client_assertion": {p.getConfiguration().AzureDevopsOAuthClientSecret}, + "grant_type": {constants.GrantTypeRefresh}, + "assertion": {string(decryptedRefreshToken)}, + "redirect_uri": {fmt.Sprintf("%s%s%s", p.GetSiteURL(), p.GetPluginURLPath(), constants.PathOAuthCallback)}, + } + + return p.StoreOAuthToken(mattermostUserID, oauthTokenFormValues) +} + +// StoreOAuthToken stores oAuth token +func (p *Plugin) StoreOAuthToken(mattermostUserID string, oauthTokenFormValues url.Values) error { successResponse, _, err := p.Client.GenerateOAuthToken(oauthTokenFormValues) if err != nil { if _, DMErr := p.DM(mattermostUserID, constants.GenericErrorMessage); DMErr != nil { From 8bc372bf975c5b624681eb0b9d5f287053e43f06 Mon Sep 17 00:00:00 2001 From: ayusht2810 Date: Tue, 16 Aug 2022 12:59:52 +0530 Subject: [PATCH 13/28] [MI-2009] API to get list of subscriptions --- server/plugin/api.go | 26 ++++++++++++++++++++++++++ server/serializers/subscriptions.go | 2 ++ server/store/subscriptions.go | 2 ++ 3 files changed, 30 insertions(+) diff --git a/server/plugin/api.go b/server/plugin/api.go index d6ce7068..34f010a1 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -41,6 +41,7 @@ func (p *Plugin) InitRoutes() { s.HandleFunc(constants.PathUnlinkProject, p.handleAuthRequired(p.checkOAuth(p.handleUnlinkProject))).Methods(http.MethodPost) s.HandleFunc(constants.PathUser, p.handleAuthRequired(p.checkOAuth(p.handleGetUserAccountDetails))).Methods(http.MethodGet) s.HandleFunc(constants.PathSubscriptions, p.handleAuthRequired(p.checkOAuth(p.handleCreateSubscriptions))).Methods(http.MethodPost) + s.HandleFunc(constants.PathSubscriptions, p.handleAuthRequired(p.checkOAuth(p.handleGetSubscriptions))).Methods(http.MethodGet) } // API to create task of a project in an organization. @@ -297,9 +298,11 @@ func (p *Plugin) handleCreateSubscriptions(w http.ResponseWriter, r *http.Reques p.Store.StoreSubscription(&serializers.SubscriptionDetails{ MattermostUserID: mattermostUserID, ProjectName: body.Project, + ProjectID: subscription.PublisherInputs.ProjectID, OrganizationName: body.Organization, EventType: body.EventType, ChannelID: channel.Id, + SubscriptionID: subscription.ID, }) response, err := json.Marshal(subscription) @@ -314,6 +317,29 @@ func (p *Plugin) handleCreateSubscriptions(w http.ResponseWriter, r *http.Reques } } +func (p *Plugin) handleGetSubscriptions(w http.ResponseWriter, r *http.Request) { + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + subscriptionList, err := p.Store.GetAllSubscriptions(mattermostUserID) + if err != nil { + p.API.LogError(constants.FetchSubscriptionListError, "Error", err.Error()) + p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + return + } + + response, err := json.Marshal(subscriptionList) + if err != nil { + p.API.LogError(constants.FetchSubscriptionListError, "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) checkOAuth(handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) diff --git a/server/serializers/subscriptions.go b/server/serializers/subscriptions.go index ece07295..bc8580a6 100644 --- a/server/serializers/subscriptions.go +++ b/server/serializers/subscriptions.go @@ -58,9 +58,11 @@ type CreateSubscriptionBodyPayload struct { type SubscriptionDetails struct { MattermostUserID string `json:"mattermostUserID"` ProjectName string `json:"projectName"` + ProjectID string `json:"projectID"` OrganizationName string `json:"organizationName"` EventType string `json:"eventType"` ChannelID string `json:"channelID"` + SubscriptionID string `json:"subscriptionID"` } func CreateSubscriptionRequestPayloadFromJSON(data io.Reader) (*CreateSubscriptionRequestPayload, error) { diff --git a/server/store/subscriptions.go b/server/store/subscriptions.go index 2067b066..74924e6b 100644 --- a/server/store/subscriptions.go +++ b/server/store/subscriptions.go @@ -51,9 +51,11 @@ func (subscriptionList *SubscriptionList) AddSubscription(userID string, subscri subscriptionListValue := serializers.SubscriptionDetails{ MattermostUserID: userID, ProjectName: subscription.ProjectName, + ProjectID: subscription.ProjectID, OrganizationName: subscription.OrganizationName, ChannelID: subscription.ChannelID, EventType: subscription.EventType, + SubscriptionID: subscription.SubscriptionID, } subscriptionList.ByMattermostUserID[userID][subscriptionKey] = subscriptionListValue } From 37bc024dddf1fc6b8422a223597def8a3dc1991f Mon Sep 17 00:00:00 2001 From: ayusht2810 Date: Tue, 16 Aug 2022 13:12:05 +0530 Subject: [PATCH 14/28] [MI-2011] Add API to listen notifications --- server/constants/messages.go | 1 + server/constants/routes.go | 24 ++++++++++----------- server/plugin/api.go | 33 +++++++++++++++++++++++++++++ server/plugin/client.go | 2 +- server/serializers/subscriptions.go | 16 ++++++++++++++ 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/server/constants/messages.go b/server/constants/messages.go index 734f212a..bd748437 100644 --- a/server/constants/messages.go +++ b/server/constants/messages.go @@ -22,6 +22,7 @@ const ( TaskTitleRequired = "task title is required" EventTypeRequired = "event type is required" ChannelNameRequired = "channel name is required" + ChannelIDRequired = "channel ID is required" ) const ( diff --git a/server/constants/routes.go b/server/constants/routes.go index 505588ec..da42030e 100644 --- a/server/constants/routes.go +++ b/server/constants/routes.go @@ -2,18 +2,18 @@ package constants const ( // Plugin API Routes - APIPrefix = "/api/v1" - WildRoute = "{anything:.*}" - PathOAuthConnect = "/oauth/connect" - PathOAuthCallback = "/oauth/complete" - PathLinkedProjects = "/project/link" - PathGetAllLinkedProjects = "/project/link" - PathUnlinkProject = "/project/unlink" - PathUser = "/user" - PathCreateTasks = "/tasks" - PathLinkProject = "/link" - PathSubscriptions = "/subscriptions" - PathSubscriptionotifications = "/notification" + APIPrefix = "/api/v1" + WildRoute = "{anything:.*}" + PathOAuthConnect = "/oauth/connect" + PathOAuthCallback = "/oauth/complete" + PathLinkedProjects = "/project/link" + PathGetAllLinkedProjects = "/project/link" + PathUnlinkProject = "/project/unlink" + PathUser = "/user" + PathCreateTasks = "/tasks" + PathLinkProject = "/link" + PathSubscriptions = "/subscriptions" + PathSubscriptionNotifications = "/notification" // Azure API paths CreateTask = "/%s/%s/_apis/wit/workitems/$%s?api-version=7.1-preview.3" diff --git a/server/plugin/api.go b/server/plugin/api.go index 34f010a1..c75704ec 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -9,6 +9,7 @@ import ( "runtime/debug" "github.com/gorilla/mux" + "github.com/mattermost/mattermost-server/v5/model" "github.com/Brightscout/mattermost-plugin-azure-devops/server/constants" "github.com/Brightscout/mattermost-plugin-azure-devops/server/serializers" @@ -42,6 +43,7 @@ func (p *Plugin) InitRoutes() { s.HandleFunc(constants.PathUser, p.handleAuthRequired(p.checkOAuth(p.handleGetUserAccountDetails))).Methods(http.MethodGet) s.HandleFunc(constants.PathSubscriptions, p.handleAuthRequired(p.checkOAuth(p.handleCreateSubscriptions))).Methods(http.MethodPost) s.HandleFunc(constants.PathSubscriptions, p.handleAuthRequired(p.checkOAuth(p.handleGetSubscriptions))).Methods(http.MethodGet) + s.HandleFunc(constants.PathSubscriptionNotifications, p.handleSubscriptionNotifications).Methods(http.MethodPost) } // API to create task of a project in an organization. @@ -340,6 +342,37 @@ func (p *Plugin) handleGetSubscriptions(w http.ResponseWriter, r *http.Request) } } +func (p *Plugin) handleSubscriptionNotifications(w http.ResponseWriter, r *http.Request) { + body, err := serializers.SubscriptionNotificationFromJSON(r.Body) + if err != nil { + p.API.LogError("Error in decoding the body for creating notifications", "Error", err.Error()) + p.handleError(w, r, &serializers.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + channelID := r.URL.Query().Get("channelID") + if channelID == "" { + p.handleError(w, r, &serializers.Error{Code: http.StatusBadRequest, Message: constants.ChannelIDRequired}) + return + } + + attachment := &model.SlackAttachment{ + Text: body.DetailedMessage.Markdown, + } + post := &model.Post{ + UserId: p.botUserID, + ChannelId: channelID, + } + + model.ParseSlackAttachment(post, []*model.SlackAttachment{attachment}) + if _, err := p.API.CreatePost(post); err != nil { + p.API.LogError("Error in creating post", "Error", err.Error()) + } + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) +} + func (p *Plugin) checkOAuth(handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) diff --git a/server/plugin/client.go b/server/plugin/client.go index eea4da14..2636d890 100644 --- a/server/plugin/client.go +++ b/server/plugin/client.go @@ -115,7 +115,7 @@ func (c *client) CreateSubscription(body *serializers.CreateSubscriptionRequestP } consumerInputs := serializers.ConsumerInputs{ - URL: fmt.Sprintf("%s%s?channelID=%s", strings.TrimRight(pluginURL, "/"), constants.PathSubscriptionotifications, channelID), + URL: fmt.Sprintf("%s%s?channelID=%s", strings.TrimRight(pluginURL, "/"), constants.PathSubscriptionNotifications, channelID), } statusData := map[string]string{ diff --git a/server/serializers/subscriptions.go b/server/serializers/subscriptions.go index bc8580a6..8a595b7a 100644 --- a/server/serializers/subscriptions.go +++ b/server/serializers/subscriptions.go @@ -65,6 +65,14 @@ type SubscriptionDetails struct { SubscriptionID string `json:"subscriptionID"` } +type DetailedMessage struct { + Markdown string `json:"markdown"` +} + +type SubscriptionNotification struct { + DetailedMessage DetailedMessage `json:"detailedMessage"` +} + func CreateSubscriptionRequestPayloadFromJSON(data io.Reader) (*CreateSubscriptionRequestPayload, error) { var body *CreateSubscriptionRequestPayload if err := json.NewDecoder(data).Decode(&body); err != nil { @@ -73,6 +81,14 @@ func CreateSubscriptionRequestPayloadFromJSON(data io.Reader) (*CreateSubscripti return body, nil } +func SubscriptionNotificationFromJSON(data io.Reader) (*SubscriptionNotification, error) { + var body *SubscriptionNotification + if err := json.NewDecoder(data).Decode(&body); err != nil { + return nil, err + } + return body, nil +} + func (t *CreateSubscriptionRequestPayload) IsSubscriptionRequestPayloadValid() error { if t.Organization == "" { return errors.New(constants.OrganizationRequired) From 4af24be83829a849ea597561f30c777fc720a80f Mon Sep 17 00:00:00 2001 From: ayusht2810 Date: Tue, 16 Aug 2022 13:24:50 +0530 Subject: [PATCH 15/28] [MI-2023] API to delete subscriptions --- server/constants/messages.go | 3 +- server/constants/routes.go | 1 + server/plugin/api.go | 46 +++++++++++++++++++++++++++++ server/plugin/client.go | 11 +++++++ server/plugin/utils.go | 2 +- server/serializers/subscriptions.go | 31 +++++++++++++++++++ 6 files changed, 92 insertions(+), 2 deletions(-) diff --git a/server/constants/messages.go b/server/constants/messages.go index bd748437..2894b287 100644 --- a/server/constants/messages.go +++ b/server/constants/messages.go @@ -15,7 +15,7 @@ const ( AlreadyLinkedProject = "This project is already linked." NoProjectLinked = "No project is linked, please link a project." - // Validations + // Validations Errors OrganizationRequired = "organization is required" ProjectRequired = "project is required" TaskTypeRequired = "task type is required" @@ -44,4 +44,5 @@ const ( ProjectNotLinked = "Requested project is not linked" GetSubscriptionListError = "Error getting Subscription List" SubscriptionAlreadyPresent = "Subscription is already present" + SubscriptionNotFound = "Requested subscription does not exists" ) diff --git a/server/constants/routes.go b/server/constants/routes.go index da42030e..1f148913 100644 --- a/server/constants/routes.go +++ b/server/constants/routes.go @@ -20,4 +20,5 @@ const ( GetTask = "%s/_apis/wit/workitems/%s?api-version=7.1-preview.3" GetProject = "/%s/_apis/projects/%s?api-version=7.1-preview.4" CreateSubscription = "/%s/_apis/hooks/subscriptions?api-version=6.0" + DeleteSubscription = "/%s/_apis/hooks/subscriptions/%s?api-version=6.0" ) diff --git a/server/plugin/api.go b/server/plugin/api.go index c75704ec..cde830ab 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -44,6 +44,7 @@ func (p *Plugin) InitRoutes() { s.HandleFunc(constants.PathSubscriptions, p.handleAuthRequired(p.checkOAuth(p.handleCreateSubscriptions))).Methods(http.MethodPost) s.HandleFunc(constants.PathSubscriptions, p.handleAuthRequired(p.checkOAuth(p.handleGetSubscriptions))).Methods(http.MethodGet) s.HandleFunc(constants.PathSubscriptionNotifications, p.handleSubscriptionNotifications).Methods(http.MethodPost) + s.HandleFunc(constants.PathSubscriptions, p.handleAuthRequired(p.checkOAuth(p.handleDeleteSubscriptions))).Methods(http.MethodDelete) } // API to create task of a project in an organization. @@ -373,6 +374,51 @@ func (p *Plugin) handleSubscriptionNotifications(w http.ResponseWriter, r *http. w.WriteHeader(http.StatusOK) } +func (p *Plugin) handleDeleteSubscriptions(w http.ResponseWriter, r *http.Request) { + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + body, err := serializers.DeleteSubscriptionRequestPayloadFromJSON(r.Body) + if err != nil { + p.API.LogError("Error in decoding the body for deleting subscriptions", "Error", err.Error()) + p.handleError(w, r, &serializers.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + if err := body.IsSubscriptionRequestPayloadValid(); err != nil { + p.handleError(w, r, &serializers.Error{Code: http.StatusBadRequest, Message: err.Error()}) + return + } + + subscriptionList, err := p.Store.GetAllSubscriptions(mattermostUserID) + if err != nil { + p.API.LogError(constants.FetchSubscriptionListError, "Error", err.Error()) + p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()}) + return + } + + subscription, isSubscriptionPresent := p.IsSubscriptionPresent(subscriptionList, serializers.SubscriptionDetails{OrganizationName: body.Organization, ProjectName: body.Project, ChannelID: body.ChannelID, EventType: body.EventType}) + if !isSubscriptionPresent { + p.API.LogError(constants.SubscriptionNotFound, "Error") + p.handleError(w, r, &serializers.Error{Code: http.StatusBadRequest, Message: constants.SubscriptionNotFound}) + return + } + + statusCode, err := p.Client.DeleteSubscription(body.Organization, subscription.SubscriptionID, mattermostUserID) + if err != nil { + p.handleError(w, r, &serializers.Error{Code: statusCode, Message: err.Error()}) + return + } + + p.Store.DeleteSubscription(&serializers.SubscriptionDetails{ + MattermostUserID: mattermostUserID, + ProjectName: body.Project, + OrganizationName: body.Organization, + EventType: body.EventType, + ChannelID: body.ChannelID, + }) + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusNoContent) +} + func (p *Plugin) checkOAuth(handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) diff --git a/server/plugin/client.go b/server/plugin/client.go index 2636d890..ddc9b858 100644 --- a/server/plugin/client.go +++ b/server/plugin/client.go @@ -21,6 +21,7 @@ type Client interface { GetTask(organization, taskID, mattermostUserID string) (*serializers.TaskValue, int, error) Link(body *serializers.LinkRequestPayload, mattermostUserID string) (*serializers.Project, int, error) CreateSubscription(body *serializers.CreateSubscriptionRequestPayload, project *serializers.ProjectDetails, channelID, pluginURL, mattermostUserID string) (*serializers.SubscriptionValue, int, error) + DeleteSubscription(organization, subscriptionID, mattermostUserID string) (int, error) } type client struct { @@ -141,6 +142,16 @@ func (c *client) CreateSubscription(body *serializers.CreateSubscriptionRequestP return subscription, statusCode, nil } +func (c *client) DeleteSubscription(organization, subscriptionID, mattermostUserID string) (int, error) { + subscriptionURL := fmt.Sprintf(constants.DeleteSubscription, organization, subscriptionID) + _, statusCode, err := c.callJSON(c.plugin.getConfiguration().AzureDevopsAPIBaseURL, subscriptionURL, http.MethodDelete, mattermostUserID, nil, nil, nil) + if err != nil { + return statusCode, errors.Wrap(err, "failed to delete subscription") + } + + return statusCode, nil +} + // Wrapper to make REST API requests with "application/json-patch+json" type content func (c *client) callPatchJSON(url, path, method, mattermostUserID string, in, out interface{}, formValues url.Values) (responseData []byte, statusCode int, err error) { contentType := "application/json-patch+json" diff --git a/server/plugin/utils.go b/server/plugin/utils.go index ed135da5..b1fcea9e 100644 --- a/server/plugin/utils.go +++ b/server/plugin/utils.go @@ -156,7 +156,7 @@ func (p *Plugin) AddAuthorization(r *http.Request, mattermostUserID string) erro if err != nil { return err } - + fmt.Println("tokrn\n\n", token) r.Header.Add(constants.Authorization, fmt.Sprintf("%s %s", constants.Bearer, token)) return nil } diff --git a/server/serializers/subscriptions.go b/server/serializers/subscriptions.go index 8a595b7a..5802f62b 100644 --- a/server/serializers/subscriptions.go +++ b/server/serializers/subscriptions.go @@ -73,6 +73,13 @@ type SubscriptionNotification struct { DetailedMessage DetailedMessage `json:"detailedMessage"` } +type DeleteSubscriptionRequestPayload struct { + Organization string `json:"organization"` + Project string `json:"project"` + EventType string `json:"eventType"` + ChannelID string `json:"channelID"` +} + func CreateSubscriptionRequestPayloadFromJSON(data io.Reader) (*CreateSubscriptionRequestPayload, error) { var body *CreateSubscriptionRequestPayload if err := json.NewDecoder(data).Decode(&body); err != nil { @@ -89,6 +96,14 @@ func SubscriptionNotificationFromJSON(data io.Reader) (*SubscriptionNotification return body, nil } +func DeleteSubscriptionRequestPayloadFromJSON(data io.Reader) (*DeleteSubscriptionRequestPayload, error) { + var body *DeleteSubscriptionRequestPayload + if err := json.NewDecoder(data).Decode(&body); err != nil { + return nil, err + } + return body, nil +} + func (t *CreateSubscriptionRequestPayload) IsSubscriptionRequestPayloadValid() error { if t.Organization == "" { return errors.New(constants.OrganizationRequired) @@ -104,3 +119,19 @@ func (t *CreateSubscriptionRequestPayload) IsSubscriptionRequestPayloadValid() e } return nil } + +func (t *DeleteSubscriptionRequestPayload) IsSubscriptionRequestPayloadValid() error { + if t.Organization == "" { + return errors.New(constants.OrganizationRequired) + } + if t.Project == "" { + return errors.New(constants.ProjectRequired) + } + if t.EventType == "" { + return errors.New(constants.EventTypeRequired) + } + if t.ChannelID == "" { + return errors.New(constants.ChannelNameRequired) + } + return nil +} From f516a00519a4c20643639e903c3323dd7e2bbfe6 Mon Sep 17 00:00:00 2001 From: ayusht2810 Date: Tue, 16 Aug 2022 13:28:04 +0530 Subject: [PATCH 16/28] [MI-2023] Remove print statement --- server/plugin/utils.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/plugin/utils.go b/server/plugin/utils.go index b1fcea9e..ed135da5 100644 --- a/server/plugin/utils.go +++ b/server/plugin/utils.go @@ -156,7 +156,7 @@ func (p *Plugin) AddAuthorization(r *http.Request, mattermostUserID string) erro if err != nil { return err } - fmt.Println("tokrn\n\n", token) + r.Header.Add(constants.Authorization, fmt.Sprintf("%s %s", constants.Bearer, token)) return nil } From 63a3628f4f72584cfc44f26ddb0af5110511e879 Mon Sep 17 00:00:00 2001 From: Abhishek Verma Date: Tue, 16 Aug 2022 14:20:03 +0530 Subject: [PATCH 17/28] [MI-1939]: Fixed statusCode --- server/plugin/client.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/plugin/client.go b/server/plugin/client.go index 75f5181a..9156a72c 100644 --- a/server/plugin/client.go +++ b/server/plugin/client.go @@ -197,10 +197,10 @@ func (c *client) call(basePath, method, path, contentType string, mattermostUser switch resp.StatusCode { case http.StatusUnauthorized, http.StatusNonAuthoritativeInfo: if err := c.plugin.RefreshOAuthToken(mattermostUserID); err != nil { - return nil, err + return nil, http.StatusInternalServerError, err } - _, err := c.call(basePath, method, path, contentType, mattermostUserID, inBody, out, formValues) - return nil, err + _, statusCode, err := c.call(basePath, method, path, contentType, mattermostUserID, inBody, out, formValues) + return nil, statusCode, err case http.StatusOK, http.StatusCreated: if out != nil { if err = json.Unmarshal(responseData, out); err != nil { From a61dd77da3bb5d0c27f74ee2a7e16dd8531ef691 Mon Sep 17 00:00:00 2001 From: ayusht2810 Date: Tue, 16 Aug 2022 14:41:26 +0530 Subject: [PATCH 18/28] [MI-2029] Add feature to create subscription from modal --- server/constants/constants.go | 5 +- server/constants/messages.go | 1 - server/constants/routes.go | 1 + server/plugin/api.go | 75 ++++-- server/plugin/command.go | 6 +- server/serializers/subscriptions.go | 8 +- webapp/src/app.tsx | 8 +- .../src/containers/SubscribeModal/index.tsx | 239 ++++++++++++++++++ .../src/containers/SubscribeModal/styles.scss | 7 + webapp/src/hooks/index.js | 9 + webapp/src/index.tsx | 2 + webapp/src/plugin_constants/index.ts | 18 +- webapp/src/reducers/index.ts | 2 + webapp/src/reducers/subscribeModal/index.ts | 19 ++ webapp/src/selectors/index.tsx | 4 + webapp/src/services/index.ts | 25 +- webapp/src/types/common/index.d.ts | 26 +- webapp/src/types/common/store.d.ts | 4 + webapp/src/utils/index.ts | 19 ++ 19 files changed, 435 insertions(+), 43 deletions(-) create mode 100644 webapp/src/containers/SubscribeModal/index.tsx create mode 100644 webapp/src/containers/SubscribeModal/styles.scss create mode 100644 webapp/src/reducers/subscribeModal/index.ts diff --git a/server/constants/constants.go b/server/constants/constants.go index f89034a6..2a445220 100644 --- a/server/constants/constants.go +++ b/server/constants/constants.go @@ -10,8 +10,6 @@ const ( PluginID = "mattermost-plugin-azure-devops" ChannelID = "channel_id" HeaderMattermostUserID = "Mattermost-User-ID" - // TODO: Change later according to the needs. - HeaderMattermostUserIDAPI = "User-ID" // Command configs CommandTriggerName = "azuredevops" @@ -38,6 +36,9 @@ const ( Update = "update" Delete = "delete" + // Path params + PathParamTeamID = "team_id" + // Authorization constants Bearer = "Bearer" Authorization = "Authorization" diff --git a/server/constants/messages.go b/server/constants/messages.go index 2894b287..65008a8f 100644 --- a/server/constants/messages.go +++ b/server/constants/messages.go @@ -21,7 +21,6 @@ const ( TaskTypeRequired = "task type is required" TaskTitleRequired = "task title is required" EventTypeRequired = "event type is required" - ChannelNameRequired = "channel name is required" ChannelIDRequired = "channel ID is required" ) diff --git a/server/constants/routes.go b/server/constants/routes.go index 1f148913..b42ecc2e 100644 --- a/server/constants/routes.go +++ b/server/constants/routes.go @@ -14,6 +14,7 @@ const ( PathLinkProject = "/link" PathSubscriptions = "/subscriptions" PathSubscriptionNotifications = "/notification" + PathGetUserChannelsForTeam = "/channels/{team_id:[A-Za-z0-9]+}" // Azure API paths CreateTask = "/%s/%s/_apis/wit/workitems/$%s?api-version=7.1-preview.3" diff --git a/server/plugin/api.go b/server/plugin/api.go index cde830ab..25bacb5d 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -45,11 +45,12 @@ func (p *Plugin) InitRoutes() { s.HandleFunc(constants.PathSubscriptions, p.handleAuthRequired(p.checkOAuth(p.handleGetSubscriptions))).Methods(http.MethodGet) s.HandleFunc(constants.PathSubscriptionNotifications, p.handleSubscriptionNotifications).Methods(http.MethodPost) s.HandleFunc(constants.PathSubscriptions, p.handleAuthRequired(p.checkOAuth(p.handleDeleteSubscriptions))).Methods(http.MethodDelete) + s.HandleFunc(constants.PathGetUserChannelsForTeam, p.handleAuthRequired(p.getUserChannelsForTeam)).Methods(http.MethodGet) } // API to create task of a project in an organization. func (p *Plugin) handleCreateTask(w http.ResponseWriter, r *http.Request) { - mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserID) body, err := serializers.CreateTaskRequestPayloadFromJSON(r.Body) if err != nil { @@ -87,7 +88,7 @@ func (p *Plugin) handleCreateTask(w http.ResponseWriter, r *http.Request) { // API to link a project and an organization to a user. func (p *Plugin) handleLink(w http.ResponseWriter, r *http.Request) { - mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserID) var body *serializers.LinkRequestPayload decoder := json.NewDecoder(r.Body) if err := decoder.Decode(&body); err != nil { @@ -135,7 +136,7 @@ func (p *Plugin) handleLink(w http.ResponseWriter, r *http.Request) { // handleGetAllLinkedProjects returns all linked projects list func (p *Plugin) handleGetAllLinkedProjects(w http.ResponseWriter, r *http.Request) { - mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserID) projectList, err := p.Store.GetAllProjects(mattermostUserID) if err != nil { p.API.LogError(constants.ErrorFetchProjectList, "Error", err.Error()) @@ -166,7 +167,7 @@ func (p *Plugin) handleGetAllLinkedProjects(w http.ResponseWriter, r *http.Reque // handleUnlinkProject unlinks a project func (p *Plugin) handleUnlinkProject(w http.ResponseWriter, r *http.Request) { - mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserID) var project *serializers.ProjectDetails decoder := json.NewDecoder(r.Body) @@ -213,7 +214,7 @@ func (p *Plugin) handleUnlinkProject(w http.ResponseWriter, r *http.Request) { // handleUnlinkProject unlinks a project func (p *Plugin) handleGetUserAccountDetails(w http.ResponseWriter, r *http.Request) { - mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserID) userDetails, err := p.Store.LoadUser(mattermostUserID) if err != nil { @@ -243,7 +244,7 @@ func (p *Plugin) handleGetUserAccountDetails(w http.ResponseWriter, r *http.Requ } func (p *Plugin) handleCreateSubscriptions(w http.ResponseWriter, r *http.Request) { - mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserID) body, err := serializers.CreateSubscriptionRequestPayloadFromJSON(r.Body) if err != nil { p.API.LogError("Error in decoding the body for creating subscriptions", "Error", err.Error()) @@ -270,14 +271,6 @@ func (p *Plugin) handleCreateSubscriptions(w http.ResponseWriter, r *http.Reques return } - // TODO: remove later - teamID := "qteks46as3befxj4ec1mip5ume" - channel, channelErr := p.API.GetChannelByName(teamID, body.ChannelName, false) - if channelErr != nil { - p.handleError(w, r, &serializers.Error{Code: http.StatusBadRequest, Message: channelErr.DetailedError}) - return - } - subscriptionList, err := p.Store.GetAllSubscriptions(mattermostUserID) if err != nil { p.API.LogError(constants.FetchSubscriptionListError, "Error", err.Error()) @@ -285,13 +278,13 @@ func (p *Plugin) handleCreateSubscriptions(w http.ResponseWriter, r *http.Reques return } - if _, isSubscriptionPresent := p.IsSubscriptionPresent(subscriptionList, serializers.SubscriptionDetails{OrganizationName: body.Organization, ProjectName: body.Project, ChannelID: channel.Id, EventType: body.EventType}); isSubscriptionPresent { + if _, isSubscriptionPresent := p.IsSubscriptionPresent(subscriptionList, serializers.SubscriptionDetails{OrganizationName: body.Organization, ProjectName: body.Project, ChannelID: body.ChannelID, EventType: body.EventType}); isSubscriptionPresent { p.API.LogError(constants.SubscriptionAlreadyPresent, "Error") p.handleError(w, r, &serializers.Error{Code: http.StatusBadRequest, Message: constants.SubscriptionAlreadyPresent}) return } - subscription, statusCode, err := p.Client.CreateSubscription(body, project, channel.Id, p.GetPluginURL(), mattermostUserID) + subscription, statusCode, err := p.Client.CreateSubscription(body, project, body.ChannelID, p.GetPluginURL(), mattermostUserID) if err != nil { p.API.LogError(constants.CreateSubscriptionError, "Error", err.Error()) p.handleError(w, r, &serializers.Error{Code: statusCode, Message: err.Error()}) @@ -304,7 +297,7 @@ func (p *Plugin) handleCreateSubscriptions(w http.ResponseWriter, r *http.Reques ProjectID: subscription.PublisherInputs.ProjectID, OrganizationName: body.Organization, EventType: body.EventType, - ChannelID: channel.Id, + ChannelID: body.ChannelID, SubscriptionID: subscription.ID, }) @@ -321,7 +314,7 @@ func (p *Plugin) handleCreateSubscriptions(w http.ResponseWriter, r *http.Reques } func (p *Plugin) handleGetSubscriptions(w http.ResponseWriter, r *http.Request) { - mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserID) subscriptionList, err := p.Store.GetAllSubscriptions(mattermostUserID) if err != nil { p.API.LogError(constants.FetchSubscriptionListError, "Error", err.Error()) @@ -375,7 +368,7 @@ func (p *Plugin) handleSubscriptionNotifications(w http.ResponseWriter, r *http. } func (p *Plugin) handleDeleteSubscriptions(w http.ResponseWriter, r *http.Request) { - mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserID) body, err := serializers.DeleteSubscriptionRequestPayloadFromJSON(r.Body) if err != nil { p.API.LogError("Error in decoding the body for deleting subscriptions", "Error", err.Error()) @@ -419,9 +412,49 @@ func (p *Plugin) handleDeleteSubscriptions(w http.ResponseWriter, r *http.Reques w.WriteHeader(http.StatusNoContent) } +func (p *Plugin) getUserChannelsForTeam(w http.ResponseWriter, r *http.Request) { + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserID) + pathParams := mux.Vars(r) + teamID := pathParams[constants.PathParamTeamID] + if !model.IsValidId(teamID) { + p.API.LogError("Invalid team id") + http.Error(w, "Invalid team id", http.StatusBadRequest) + return + } + + channels, channelErr := p.API.GetChannelsForTeamForUser(teamID, mattermostUserID, false) + if channelErr != nil { + p.API.LogError("Error in getting channels for team and user", "Error", channelErr.Error()) + http.Error(w, fmt.Sprintf("Error in getting channels for team and user. Error: %s", channelErr.Error()), channelErr.StatusCode) + return + } + + w.Header().Set("Content-Type", "application/json") + if channels == nil { + _, _ = w.Write([]byte("[]")) + return + } + + var requiredChannels []*model.Channel + for _, channel := range channels { + if channel.Type == model.CHANNEL_PRIVATE || channel.Type == model.CHANNEL_OPEN { + requiredChannels = append(requiredChannels, channel) + } + } + if requiredChannels == nil { + _, _ = w.Write([]byte("[]")) + return + } + + if err := json.NewEncoder(w).Encode(requiredChannels); err != nil { + p.API.LogError("Error while writing response", "Error", err.Error()) + w.WriteHeader(http.StatusInternalServerError) + } +} + func (p *Plugin) checkOAuth(handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserID) user, err := p.Store.LoadUser(mattermostUserID) if err != nil || user.AccessToken == "" { if errors.Is(err, ErrNotFound) || user.AccessToken == "" { @@ -439,7 +472,7 @@ func (p *Plugin) checkOAuth(handler http.HandlerFunc) http.HandlerFunc { // handleAuthRequired verifies if the provided request is performed by an authorized source. func (p *Plugin) handleAuthRequired(handleFunc http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - mattermostUserID := r.Header.Get(constants.HeaderMattermostUserIDAPI) + mattermostUserID := r.Header.Get(constants.HeaderMattermostUserID) if mattermostUserID == "" { error := serializers.Error{Code: http.StatusUnauthorized, Message: constants.NotAuthorized} p.handleError(w, r, &error) diff --git a/server/plugin/command.go b/server/plugin/command.go index faf5eef5..b6df934b 100644 --- a/server/plugin/command.go +++ b/server/plugin/command.go @@ -26,6 +26,7 @@ var azureDevopsCommandHandler = Handler{ "connect": azureDevopsConnectCommand, "disconnect": azureDevopsDisconnectCommand, "link": azureDevopsAccountConnectionCheck, + "subscribe": azureDevopsAccountConnectionCheck, }, defaultHandler: executeDefault, } @@ -42,7 +43,7 @@ func (ch *Handler) Handle(p *Plugin, c *plugin.Context, commandArgs *model.Comma } func (p *Plugin) getAutoCompleteData() *model.AutocompleteData { - azureDevops := model.NewAutocompleteData(constants.CommandTriggerName, "[command]", "Available commands: help, connect, disconnect, create, link") + azureDevops := model.NewAutocompleteData(constants.CommandTriggerName, "[command]", "Available commands: help, connect, disconnect, create, link, subscribe") help := model.NewAutocompleteData("help", "", fmt.Sprintf("Show %s slash command help", constants.CommandTriggerName)) azureDevops.AddCommand(help) @@ -59,6 +60,9 @@ func (p *Plugin) getAutoCompleteData() *model.AutocompleteData { link := model.NewAutocompleteData("link", "[link]", "link a project") azureDevops.AddCommand(link) + subscribe := model.NewAutocompleteData("subscribe", "", "Add a subscription") + azureDevops.AddCommand(subscribe) + return azureDevops } diff --git a/server/serializers/subscriptions.go b/server/serializers/subscriptions.go index 5802f62b..cb99ce2f 100644 --- a/server/serializers/subscriptions.go +++ b/server/serializers/subscriptions.go @@ -43,7 +43,7 @@ type CreateSubscriptionRequestPayload struct { Organization string `json:"organization"` Project string `json:"project"` EventType string `json:"eventType"` - ChannelName string `json:"channelName"` + ChannelID string `json:"channelID"` } type CreateSubscriptionBodyPayload struct { @@ -114,8 +114,8 @@ func (t *CreateSubscriptionRequestPayload) IsSubscriptionRequestPayloadValid() e if t.EventType == "" { return errors.New(constants.EventTypeRequired) } - if t.ChannelName == "" { - return errors.New(constants.ChannelNameRequired) + if t.ChannelID == "" { + return errors.New(constants.ChannelIDRequired) } return nil } @@ -131,7 +131,7 @@ func (t *DeleteSubscriptionRequestPayload) IsSubscriptionRequestPayloadValid() e return errors.New(constants.EventTypeRequired) } if t.ChannelID == "" { - return errors.New(constants.ChannelNameRequired) + return errors.New(constants.ChannelIDRequired) } return nil } diff --git a/webapp/src/app.tsx b/webapp/src/app.tsx index 26a8a7da..fb3845e9 100644 --- a/webapp/src/app.tsx +++ b/webapp/src/app.tsx @@ -3,9 +3,10 @@ import {useDispatch} from 'react-redux'; import usePluginApi from 'hooks/usePluginApi'; -import {getGlobalModalState, getLinkModalState} from 'selectors'; +import {getGlobalModalState, getLinkModalState, getSubscribeModalState} from 'selectors'; import {toggleShowLinkModal} from 'reducers/linkModal'; +import {toggleShowSubscribeModal} from 'reducers/subscribeModal'; import {resetGlobalModalState} from 'reducers/globalModal'; // Global styles @@ -32,6 +33,9 @@ const App = (): JSX.Element => { case 'linkProject': dispatch(toggleShowLinkModal({isVisible: true, commandArgs})); break; + case 'subscribeProject': + dispatch(toggleShowSubscribeModal({isVisible: true, commandArgs})); + break; } } else { dispatch(resetGlobalModalState()); @@ -40,7 +44,7 @@ const App = (): JSX.Element => { useEffect(() => { dispatch(resetGlobalModalState()); - }, [getLinkModalState(usePlugin.state).visibility]); + }, [getLinkModalState(usePlugin.state).visibility, getSubscribeModalState(usePlugin.state).visibility]); return <>; }; diff --git a/webapp/src/containers/SubscribeModal/index.tsx b/webapp/src/containers/SubscribeModal/index.tsx new file mode 100644 index 00000000..ffe4a41b --- /dev/null +++ b/webapp/src/containers/SubscribeModal/index.tsx @@ -0,0 +1,239 @@ +import React, {useCallback, useEffect, useState} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import {GlobalState} from 'mattermost-redux/types/store'; + +import Modal from 'components/modal'; + +import usePluginApi from 'hooks/usePluginApi'; +import {getSubscribeModalState} from 'selectors'; +import plugin_constants from 'plugin_constants'; +import {toggleShowSubscribeModal} from 'reducers/subscribeModal'; +import Dropdown from 'components/dropdown'; +import {getOrganizationList, getProjectList} from 'utils'; + +import './styles.scss'; + +const SubscribeModal = () => { + const eventTypeOptions = [ + { + value: 'create', + label: 'Create', + }, + { + value: 'update', + label: 'Update', + }, + { + value: 'delete', + label: 'Delete', + }, + ]; + + const [subscriptionDetails, setSubscriptionDetails] = useState({ + organization: '', + project: '', + eventType: '', + channelID: '', + }); + const [errorState, setErrorState] = useState({ + organization: '', + project: '', + eventType: '', + channelID: '', + }); + const [subscriptionPayload, setSubscriptionPayload] = useState(); + const [loading, setLoading] = useState(false); + const [APIError, setAPIError] = useState(''); + const [channelOptions, setChannelOptions] = useState([]); + const [organizationOptions, setOrganizationOptions] = useState([]); + const [projectOptions, setProjectOptions] = useState([]); + const {entities} = useSelector((state: GlobalState) => state); + const usePlugin = usePluginApi(); + const {visibility} = getSubscribeModalState(usePlugin.state); + const dispatch = useDispatch(); + + // Get ProjectList State + const getProjectState = () => { + const {isLoading, isSuccess, isError, data} = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName); + return {isLoading, isSuccess, isError, data: data as ProjectDetails[]}; + }; + + // Get ChannelList State + const getChannelState = () => { + const {isLoading, isSuccess, isError, data} = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getChannels.apiServiceName, {teamId: entities.teams.currentTeamId}); + return {isLoading, isSuccess, isError, data: data as ChannelList[]}; + }; + + useEffect(() => { + if (!getChannelState().data) { + usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.getChannels.apiServiceName, {teamId: entities.teams.currentTeamId}); + } + if (!getProjectState().data) { + usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName); + } + }, []); + + useEffect(() => { + // Pre-select the dropdown value in case of single option. + if (organizationOptions.length === 1) { + setSubscriptionDetails((value) => ({...value, organization: organizationOptions[0].value})); + } + if (projectOptions.length === 1) { + setSubscriptionDetails((value) => ({...value, project: projectOptions[0].value})); + } + if (channelOptions.length === 1) { + setSubscriptionDetails((value) => ({...value, channelID: channelOptions[0].value})); + } + }, [projectOptions, organizationOptions, channelOptions]); + + // Function to hide the modal and reset all the states. + const onHide = useCallback(() => { + setSubscriptionDetails({ + organization: '', + project: '', + eventType: '', + channelID: '', + }); + setErrorState({ + organization: '', + project: '', + eventType: '', + channelID: '', + }); + setLoading(false); + setAPIError(''); + setSubscriptionPayload(null); + dispatch(toggleShowSubscribeModal({isVisible: false, commandArgs: []})); + }, []); + + // Set organization name + const onOrganizationChange = useCallback((value: string) => { + setErrorState({...errorState, organization: ''}); + setSubscriptionDetails({...subscriptionDetails, organization: value}); + }, [subscriptionDetails, errorState]); + + // Set project name + const onProjectChange = useCallback((value: string) => { + setErrorState({...errorState, project: ''}); + setSubscriptionDetails({...subscriptionDetails, project: value}); + }, [subscriptionDetails, errorState]); + + // Set event type + const onEventTypeChange = useCallback((value: string) => { + setErrorState({...errorState, eventType: ''}); + setSubscriptionDetails({...subscriptionDetails, eventType: value}); + }, [subscriptionDetails, errorState]); + + // Set channel name + const onChannelChange = useCallback((value: string) => { + setErrorState({...errorState, channelID: ''}); + setSubscriptionDetails({...subscriptionDetails, channelID: value}); + }, [subscriptionDetails, errorState]); + + // Handles on confirming subscription + const onConfirm = useCallback(() => { + if (subscriptionDetails.organization === '') { + setErrorState((value) => ({...value, organization: 'Organization is required'})); + } + if (subscriptionDetails.project === '') { + setErrorState((value) => ({...value, project: 'Project is required'})); + } + if (subscriptionDetails.eventType === '') { + setErrorState((value) => ({...value, eventType: 'Event type is required'})); + } + if (subscriptionDetails.channelID === '') { + setErrorState((value) => ({...value, channelID: 'Channel name is required'})); + } + if (!subscriptionDetails.organization || !subscriptionDetails.project || !subscriptionDetails.channelID || !subscriptionDetails.eventType) { + return; + } + + // Create payload to send in the POST request. + const payload = { + organization: subscriptionDetails.organization, + project: subscriptionDetails.project, + channelID: subscriptionDetails.channelID, + eventType: subscriptionDetails.eventType, + }; + + setSubscriptionPayload(payload); + + // Make POST api request + usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.createSubscription.apiServiceName, payload); + }, [subscriptionDetails]); + + useEffect(() => { + const channelList = getChannelState().data; + if (channelList) { + setChannelOptions(channelList.map((channel) => ({label: {channel.display_name}, value: channel.id}))); + } + + const projectList = getProjectState().data; + if (projectList) { + setProjectOptions(getProjectList(projectList)); + setOrganizationOptions(getOrganizationList(projectList)); + } + if (subscriptionPayload) { + const {isLoading, isSuccess, isError} = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.createSubscription.apiServiceName, subscriptionPayload); + setLoading(isLoading); + if (isSuccess) { + onHide(); + } + if (isError) { + setAPIError('Some error occurred. Please try again later.'); + } + } + }, [usePlugin.state]); + + return ( + + <> + onOrganizationChange(newValue)} + options={organizationOptions} + required={true} + error={errorState.organization} + /> + onProjectChange(newValue)} + options={projectOptions} + required={true} + error={errorState.project} + /> + onEventTypeChange(newValue)} + options={eventTypeOptions} + required={true} + error={errorState.eventType} + /> + onChannelChange(newValue)} + options={channelOptions} + required={true} + error={errorState.channelID} + /> + + + ); +}; + +export default SubscribeModal; diff --git a/webapp/src/containers/SubscribeModal/styles.scss b/webapp/src/containers/SubscribeModal/styles.scss new file mode 100644 index 00000000..3c036beb --- /dev/null +++ b/webapp/src/containers/SubscribeModal/styles.scss @@ -0,0 +1,7 @@ +.dropdown { + margin-bottom: 12px; + + .dropdown-option-icon { + margin-right: 8px; + } +} diff --git a/webapp/src/hooks/index.js b/webapp/src/hooks/index.js index 8973127b..597a1c10 100644 --- a/webapp/src/hooks/index.js +++ b/webapp/src/hooks/index.js @@ -27,6 +27,15 @@ export default class Hooks { return Promise.resolve({}); } + if (commandTrimmed && commandTrimmed.startsWith('/azuredevops subscribe')) { + const commandArgs = getCommandArgs(commandTrimmed); + this.store.dispatch(setGlobalModalState({modalId: 'subscribeProject', commandArgs})); + return { + message, + args: contextArgs, + }; + } + return Promise.resolve({ message, args: contextArgs, diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index 0f00670c..cf1f9a1a 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -16,6 +16,7 @@ import Hooks from 'hooks'; import Rhs from 'containers/Rhs'; import LinkModal from 'containers/LinkModal'; import TaskModal from 'containers/TaskModal'; +import SubscribeModal from 'containers/SubscribeModal'; // eslint-disable-next-line import/no-unresolved import {PluginRegistry} from './types/mattermost-webapp'; @@ -28,6 +29,7 @@ export default class Plugin { registry.registerRootComponent(App); registry.registerRootComponent(TaskModal); registry.registerRootComponent(LinkModal); + registry.registerRootComponent(SubscribeModal); registry.registerWebSocketEventHandler(`custom_${Constants.pluginId}_connect`, handleConnect(store)); registry.registerWebSocketEventHandler(`custom_${Constants.pluginId}_disconnect`, handleDisconnect(store)); const {showRHSPlugin} = registry.registerRightHandSidebarComponent(Rhs, Constants.RightSidebarHeader); diff --git a/webapp/src/plugin_constants/index.ts b/webapp/src/plugin_constants/index.ts index 6bceb421..1d1ab563 100644 --- a/webapp/src/plugin_constants/index.ts +++ b/webapp/src/plugin_constants/index.ts @@ -8,8 +8,8 @@ const pluginId = 'mattermost-plugin-azure-devops'; const AzureDevops = 'Azure Devops'; const RightSidebarHeader = 'Azure Devops'; -const MMUSERID = 'MMUSERID'; -const HeaderMattermostUserID = 'User-ID'; +const MMCSRF = 'MMCSRF'; +const HeaderCSRFToken = 'X-CSRF-Token'; // Plugin api service (RTK query) configs const pluginApiServiceConfigs: Record = { @@ -43,11 +43,21 @@ const pluginApiServiceConfigs: Record = { method: 'GET', apiServiceName: 'getUserDetails', }, + createSubscription: { + path: '/subscriptions', + method: 'POST', + apiServiceName: 'createSubscription', + }, + getChannels: { + path: '/channels', + method: 'GET', + apiServiceName: 'getChannels', + }, }; export default { - MMUSERID, - HeaderMattermostUserID, + MMCSRF, + HeaderCSRFToken, pluginId, pluginApiServiceConfigs, AzureDevops, diff --git a/webapp/src/reducers/index.ts b/webapp/src/reducers/index.ts index d4f82a58..e46d41e7 100644 --- a/webapp/src/reducers/index.ts +++ b/webapp/src/reducers/index.ts @@ -4,6 +4,7 @@ import services from 'services'; import globalModalSlice from './globalModal'; import openLinkModalSlice from './linkModal'; +import openSubscribeModalSlice from './subscribeModal'; import openTaskModalReducer from './taskModal'; import projectDetailsSlice from './projectDetails'; import userConnectedSlice from './userConnected'; @@ -13,6 +14,7 @@ const reducers = combineReducers({ globalModalSlice, openLinkModalSlice, openTaskModalReducer, + openSubscribeModalSlice, projectDetailsSlice, userConnectedSlice, testReducer, diff --git a/webapp/src/reducers/subscribeModal/index.ts b/webapp/src/reducers/subscribeModal/index.ts new file mode 100644 index 00000000..2dbae6ab --- /dev/null +++ b/webapp/src/reducers/subscribeModal/index.ts @@ -0,0 +1,19 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; + +const initialState: SubscribeModalState = { + visibility: false, +}; + +export const openSubscribeModalSlice = createSlice({ + name: 'openSubscribeModal', + initialState, + reducers: { + toggleShowSubscribeModal: (state: SubscribeModalState, action: PayloadAction) => { + state.visibility = action.payload.isVisible; + }, + }, +}); + +export const {toggleShowSubscribeModal} = openSubscribeModalSlice.actions; + +export default openSubscribeModalSlice.reducer; diff --git a/webapp/src/selectors/index.tsx b/webapp/src/selectors/index.tsx index 60470a5a..86618930 100644 --- a/webapp/src/selectors/index.tsx +++ b/webapp/src/selectors/index.tsx @@ -19,3 +19,7 @@ export const getLinkModalState = (state: any): LinkProjectModalState => { export const getRhsState = (state: any): {isSidebarOpen: boolean} => { return state.views.rhs; }; + +export const getSubscribeModalState = (state: any): SubscribeModalState => { + return state[pluginPrefix].openSubscribeModalSlice; +}; diff --git a/webapp/src/services/index.ts b/webapp/src/services/index.ts index 8456a77d..c31ce8be 100644 --- a/webapp/src/services/index.ts +++ b/webapp/src/services/index.ts @@ -13,7 +13,7 @@ const pluginApi = createApi({ endpoints: (builder) => ({ [Constants.pluginApiServiceConfigs.createTask.apiServiceName]: builder.query({ query: (payload) => ({ - headers: {[Constants.HeaderMattermostUserID]: Cookies.get(Constants.MMUSERID)}, + headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, url: Constants.pluginApiServiceConfigs.createTask.path, method: Constants.pluginApiServiceConfigs.createTask.method, body: payload, @@ -21,7 +21,7 @@ const pluginApi = createApi({ }), [Constants.pluginApiServiceConfigs.createLink.apiServiceName]: builder.query({ query: (payload) => ({ - headers: {[Constants.HeaderMattermostUserID]: Cookies.get(Constants.MMUSERID)}, + headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, url: Constants.pluginApiServiceConfigs.createLink.path, method: Constants.pluginApiServiceConfigs.createLink.method, body: payload, @@ -29,14 +29,14 @@ const pluginApi = createApi({ }), [Constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName]: builder.query({ query: () => ({ - headers: {[Constants.HeaderMattermostUserID]: Cookies.get(Constants.MMUSERID)}, + headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, 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)}, + headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, url: Constants.pluginApiServiceConfigs.unlinkProject.path, method: Constants.pluginApiServiceConfigs.unlinkProject.method, body: payload, @@ -44,11 +44,26 @@ const pluginApi = createApi({ }), [Constants.pluginApiServiceConfigs.getUserDetails.apiServiceName]: builder.query({ query: () => ({ - headers: {[Constants.HeaderMattermostUserID]: Cookies.get(Constants.MMUSERID)}, + headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, url: Constants.pluginApiServiceConfigs.getUserDetails.path, method: Constants.pluginApiServiceConfigs.getUserDetails.method, }), }), + [Constants.pluginApiServiceConfigs.createSubscription.apiServiceName]: builder.query({ + query: (payload) => ({ + headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, + url: Constants.pluginApiServiceConfigs.createSubscription.path, + method: Constants.pluginApiServiceConfigs.createSubscription.method, + body: payload, + }), + }), + [Constants.pluginApiServiceConfigs.getChannels.apiServiceName]: builder.query({ + query: (params) => ({ + headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, + url: `${Constants.pluginApiServiceConfigs.getChannels.path}/${params.teamId}`, + method: Constants.pluginApiServiceConfigs.getChannels.method, + }), + }), }), }); diff --git a/webapp/src/types/common/index.d.ts b/webapp/src/types/common/index.d.ts index 364af174..b28b62e3 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' | 'getUserDetails' +type ApiServiceName = 'createTask' | 'testGet' | 'createLink' | 'getAllLinkedProjectsList' | 'unlinkProject' | 'getUserDetails' | 'createSubscription' | 'getChannels' type PluginApiService = { path: string, @@ -38,7 +38,14 @@ type CreateTaskPayload = { fields: CreateTaskFields, } -type APIRequestPayload = CreateTaskPayload | LinkPayload | ProjectDetails | UserDetails | void; +type SubscriptionPayload = { + organization: string, + project: string, + eventType: string, + channelID: string +} + +type APIRequestPayload = CreateTaskPayload | LinkPayload | ProjectDetails | UserDetails | SubscriptionPayload | FetchChannelParams | void; type DropdownOptionType = { label?: string | JSX.Element; @@ -61,6 +68,19 @@ type UserDetails = { MattermostUserID: string } +type ChannelList = { + display_name: string, + id: string, + name: string, + team_id: string, + team_name: string, + type: string +} + +type FetchChannelParams = { + teamId: string; +} + type eventType = 'create' | 'update' | 'delete' type SubscriptionDetails = { @@ -69,4 +89,4 @@ type SubscriptionDetails = { eventType: eventType } -type ModalId = 'linkProject' | 'createBoardTask' | null +type ModalId = 'linkProject' | 'createBoardTask' | 'subscribeProject' | null diff --git a/webapp/src/types/common/store.d.ts b/webapp/src/types/common/store.d.ts index ccf44819..f3654422 100644 --- a/webapp/src/types/common/store.d.ts +++ b/webapp/src/types/common/store.d.ts @@ -14,3 +14,7 @@ type LinkProjectModalState = { project: string, isLinked: boolean, } + +type SubscribeModalState = { + visibility: boolean, +} diff --git a/webapp/src/utils/index.ts b/webapp/src/utils/index.ts index a38446e1..a7609908 100644 --- a/webapp/src/utils/index.ts +++ b/webapp/src/utils/index.ts @@ -50,6 +50,25 @@ export const onPressingEnterKey = (event: Event | undefined, func: () => void) = func(); }; +export const getProjectList = (data: ProjectDetails[]) => { + const projectList: DropdownOptionType[] = []; + data.map((project) => projectList.push({value: project.projectName, label: project.projectName})); + return projectList; +}; + +export const getOrganizationList = (data: ProjectDetails[]) => { + const uniqueOrganization: Record = {}; + const organizationList: DropdownOptionType[] = []; + data.map((organization) => { + if (!(organization.organizationName in uniqueOrganization)) { + uniqueOrganization[organization.organizationName] = true; + organizationList.push({value: organization.organizationName, label: organization.organizationName}); + } + return organizationList; + }); + return organizationList; +}; + export default { getBaseUrls, }; From df6ce21f4ba91491bbf9e31d074be4244ef42650 Mon Sep 17 00:00:00 2001 From: ayusht2810 Date: Tue, 16 Aug 2022 14:49:26 +0530 Subject: [PATCH 19/28] [MI-2030] Add filter to fetch subscriptions related to project --- server/constants/constants.go | 3 +++ server/plugin/api.go | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/server/constants/constants.go b/server/constants/constants.go index 2a445220..ec83d5ce 100644 --- a/server/constants/constants.go +++ b/server/constants/constants.go @@ -39,6 +39,9 @@ const ( // Path params PathParamTeamID = "team_id" + // URL query params constants + ProjectParam = "project" + // Authorization constants Bearer = "Bearer" Authorization = "Authorization" diff --git a/server/plugin/api.go b/server/plugin/api.go index 25bacb5d..4bfe1cc9 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -322,6 +322,17 @@ func (p *Plugin) handleGetSubscriptions(w http.ResponseWriter, r *http.Request) return } + project := r.URL.Query().Get(constants.ProjectParam) + if project != "" { + subscriptionByProject := []serializers.SubscriptionDetails{} + for _, subscription := range subscriptionList { + if subscription.ProjectName == project { + subscriptionByProject = append(subscriptionByProject, subscription) + } + } + subscriptionList = subscriptionByProject + } + response, err := json.Marshal(subscriptionList) if err != nil { p.API.LogError(constants.FetchSubscriptionListError, "Error", err.Error()) From 4ecb8e71c39bba27d500e46bf932f53006a11265 Mon Sep 17 00:00:00 2001 From: Abhishek Verma Date: Tue, 16 Aug 2022 16:18:50 +0530 Subject: [PATCH 20/28] [MI-2056]: Fixed create task flow and added user connection check on reload --- server/plugin/api.go | 8 + server/plugin/command.go | 1 + webapp/src/app.tsx | 17 +- webapp/src/components/modal/index.tsx | 3 +- webapp/src/containers/LinkModal/index.tsx | 12 +- webapp/src/containers/TaskModal/index.tsx | 329 ++++++++++++---------- webapp/src/hooks/index.js | 9 +- webapp/src/reducers/taskModal/index.ts | 29 +- webapp/src/selectors/index.tsx | 4 + webapp/src/types/common/store.d.ts | 10 + webapp/src/utils/index.ts | 14 + 11 files changed, 262 insertions(+), 174 deletions(-) diff --git a/server/plugin/api.go b/server/plugin/api.go index 57ba6549..b4508416 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -9,6 +9,8 @@ import ( "github.com/gorilla/mux" + "github.com/mattermost/mattermost-server/v5/model" + "github.com/Brightscout/mattermost-plugin-azure-devops/server/constants" "github.com/Brightscout/mattermost-plugin-azure-devops/server/serializers" ) @@ -256,6 +258,12 @@ func (p *Plugin) handleGetUserAccountDetails(w http.ResponseWriter, r *http.Requ return } + p.API.PublishWebSocketEvent( + constants.WSEventConnect, + nil, + &model.WebsocketBroadcast{UserId: mattermostUserID}, + ) + w.Header().Add("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if _, err := w.Write(response); err != nil { diff --git a/server/plugin/command.go b/server/plugin/command.go index faf5eef5..eda1f7bc 100644 --- a/server/plugin/command.go +++ b/server/plugin/command.go @@ -26,6 +26,7 @@ var azureDevopsCommandHandler = Handler{ "connect": azureDevopsConnectCommand, "disconnect": azureDevopsDisconnectCommand, "link": azureDevopsAccountConnectionCheck, + "boards": azureDevopsAccountConnectionCheck, }, defaultHandler: executeDefault, } diff --git a/webapp/src/app.tsx b/webapp/src/app.tsx index 26a8a7da..c948827f 100644 --- a/webapp/src/app.tsx +++ b/webapp/src/app.tsx @@ -3,9 +3,12 @@ import {useDispatch} from 'react-redux'; import usePluginApi from 'hooks/usePluginApi'; -import {getGlobalModalState, getLinkModalState} from 'selectors'; +import {getGlobalModalState, getLinkModalState, getCreateTaskModalState} from 'selectors'; + +import plugin_constants from 'plugin_constants'; import {toggleShowLinkModal} from 'reducers/linkModal'; +import {toggleShowTaskModal} from 'reducers/taskModal'; import {resetGlobalModalState} from 'reducers/globalModal'; // Global styles @@ -18,6 +21,11 @@ const App = (): JSX.Element => { const usePlugin = usePluginApi(); const dispatch = useDispatch(); + // Check if user is connected on page reload + useEffect(() => { + usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName); + }, []); + /** * 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 @@ -32,6 +40,8 @@ const App = (): JSX.Element => { case 'linkProject': dispatch(toggleShowLinkModal({isVisible: true, commandArgs})); break; + case 'createBoardTask': + dispatch(toggleShowTaskModal({isVisible: true, commandArgs})); } } else { dispatch(resetGlobalModalState()); @@ -40,7 +50,10 @@ const App = (): JSX.Element => { useEffect(() => { dispatch(resetGlobalModalState()); - }, [getLinkModalState(usePlugin.state).visibility]); + }, [ + getLinkModalState(usePlugin.state).visibility, + getCreateTaskModalState(usePlugin.state).visibility, + ]); return <>; }; diff --git a/webapp/src/components/modal/index.tsx b/webapp/src/components/modal/index.tsx index 0b536dd0..f6c7f918 100644 --- a/webapp/src/components/modal/index.tsx +++ b/webapp/src/components/modal/index.tsx @@ -25,7 +25,7 @@ type ModalProps = { cancelDisabled?: boolean; } -const Modal = ({show, onHide, showCloseIconInHeader = true, children, title, subTitle, onConfirm, confirmAction, confirmBtnText, cancelBtnText, className = '', loading = false, error}: ModalProps) => { +const Modal = ({show, onHide, showCloseIconInHeader = true, children, title, subTitle, onConfirm, confirmAction, confirmBtnText, cancelBtnText, confirmDisabled, className = '', loading = false, error}: ModalProps) => { return ( ); diff --git a/webapp/src/containers/LinkModal/index.tsx b/webapp/src/containers/LinkModal/index.tsx index b8c3a509..9fac24a1 100644 --- a/webapp/src/containers/LinkModal/index.tsx +++ b/webapp/src/containers/LinkModal/index.tsx @@ -71,13 +71,13 @@ const LinkModal = () => { } // Make POST api request - linkTask(projectDetails); + linkTask(); }; // Make POST api request to link a project - const linkTask = async (payload: LinkPayload) => { - const createTaskRequest = await usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.createLink.apiServiceName, payload); - if (createTaskRequest) { + const linkTask = async () => { + const linkProjectResponse = await usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.createLink.apiServiceName, projectDetails); + if (linkProjectResponse) { dispatch(toggleIsLinked(true)); resetModalState(); } @@ -111,6 +111,7 @@ const LinkModal = () => { value={projectDetails.organization} onChange={onOrganizationChange} error={errorState.organization} + disabled={isLoading} required={true} /> { placeholder='Project name' value={projectDetails.project} onChange={onProjectChange} - required={true} + disabled={isLoading} error={errorState.project} + required={true} /> diff --git a/webapp/src/containers/TaskModal/index.tsx b/webapp/src/containers/TaskModal/index.tsx index 440bd271..93ae241f 100644 --- a/webapp/src/containers/TaskModal/index.tsx +++ b/webapp/src/containers/TaskModal/index.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {useDispatch} from 'react-redux'; import Dropdown from 'components/dropdown'; @@ -6,8 +6,10 @@ import Input from 'components/inputField'; import Modal from 'components/modal'; import Constants from 'plugin_constants'; + import usePluginApi from 'hooks/usePluginApi'; -import {hideTaskModal} from 'reducers/taskModal'; +import {toggleShowTaskModal} from 'reducers/taskModal'; +import {getCreateTaskModalState} from 'selectors'; // TODO: fetch the organization and project options from API later. const organizationOptions = [ @@ -56,175 +58,198 @@ const taskTypeOptions = [ ]; const TaskModal = () => { - const [state, setState] = useState({ - taskOrganization: '', - taskProject: '', - taskType: '', - taskTitle: '', - taskDescription: '', + // State variables + const [taskDetails, setTaskDetails] = useState({ + organization: '', + project: '', + type: '', + fields: { + title: '', + description: '', + }, + }); + const [taskDetailsError, setTaskDetailsError] = useState({ + organization: '', + project: '', + type: '', + fields: { + title: '', + description: '', + }, }); - const [taskOrganizationError, setTaskOrganizationError] = useState(''); - const [taskProjectError, setTaskProjectError] = useState(''); - const [taskTypeError, setTaskTypeError] = useState(''); - const [taskTitleError, setTaskTitleError] = useState(''); - const [taskPayload, setTaskPayload] = useState(); + + // Hooks const usePlugin = usePluginApi(); - const {visibility} = usePlugin.state['plugins-mattermost-plugin-azure-devops'].openTaskModalReducer; const dispatch = useDispatch(); - useEffect(() => { - if (visibility === true) { - // Pre-select the dropdown value in case of single option. - if (organizationOptions.length === 1) { - setState({...state, taskOrganization: organizationOptions[0].value}); - } - if (projectOptions.length === 1) { - setState({...state, taskProject: projectOptions[0].value}); - } - } - }, [visibility]); - // Function to hide the modal and reset all the states. - const onHide = useCallback(() => { - setState({ - taskOrganization: '', - taskProject: '', - taskType: '', - taskTitle: '', - taskDescription: '', + const resetModalState = () => { + setTaskDetails({ + organization: '', + project: '', + type: '', + fields: { + title: '', + description: '', + }, + }); + setTaskDetailsError({ + organization: '', + project: '', + type: '', + fields: { + title: '', + description: '', + }, }); - setTaskOrganizationError(''); - setTaskProjectError(''); - setTaskTitleError(''); - setTaskTypeError(''); - setTaskPayload(null); - dispatch(hideTaskModal()); - }, []); - - const onOrganizationChange = useCallback((value: string) => { - setTaskOrganizationError(''); - setState({...state, taskOrganization: value}); - }, [state]); - - const onProjectChange = useCallback((value: string) => { - setTaskProjectError(''); - setState({...state, taskProject: value}); - }, [state]); - - const onTaskTypeChange = useCallback((value: string) => { - setTaskTypeError(''); - setState({...state, taskType: value}); - }, [state]); - - const onTitleChange = useCallback((e: React.ChangeEvent) => { - setTaskTitleError(''); - setState({...state, taskTitle: (e.target as HTMLInputElement).value}); - }, [state]); - - const onDescriptionChange = useCallback((e: React.ChangeEvent) => { - setState({...state, taskDescription: (e.target as HTMLInputElement).value}); - }, [state]); - - const onConfirm = useCallback(() => { - if (state.taskOrganization === '') { - setTaskOrganizationError('Organization is required'); + dispatch(toggleShowTaskModal({isVisible: false, commandArgs: []})); + }; + + const onOrganizationChange = (value: string) => { + setTaskDetailsError({...taskDetailsError, organization: ''}); + setTaskDetails({...taskDetails, organization: value}); + }; + + const onProjectChange = (value: string) => { + setTaskDetailsError({...taskDetailsError, project: ''}); + setTaskDetails({...taskDetails, project: value}); + }; + + const onTaskTypeChange = (value: string) => { + setTaskDetailsError({...taskDetailsError, type: ''}); + setTaskDetails({...taskDetails, type: value}); + }; + + const onTitleChange = (e: React.ChangeEvent) => { + setTaskDetailsError({...taskDetailsError, fields: {...taskDetailsError.fields, title: ''}}); + setTaskDetails({...taskDetails, fields: {...taskDetails.fields, title: (e.target as HTMLInputElement).value}}); + }; + + const onDescriptionChange = (e: React.ChangeEvent) => { + setTaskDetailsError({...taskDetailsError, fields: {...taskDetailsError.fields, description: ''}}); + setTaskDetails({...taskDetails, fields: {...taskDetails.fields, description: (e.target as HTMLInputElement).value}}); + }; + + const onConfirm = () => { + const errorState: CreateTaskPayload = { + organization: '', + project: '', + type: '', + fields: { + title: '', + description: '', + }, + }; + + if (taskDetails.organization === '') { + errorState.organization = 'Organization is required'; } - if (state.taskProject === '') { - setTaskProjectError('Project is required'); + if (taskDetails.project === '') { + errorState.project = 'Project is required'; } - if (state.taskType === '') { - setTaskTypeError('Work item type is required'); + if (taskDetails.type === '') { + errorState.type = 'Work item type is required'; } - if (state.taskTitle === '') { - setTaskTitleError('Title is required'); + if (taskDetails.fields.title === '') { + errorState.fields.title = 'Title is required'; } - if (!state.taskOrganization || !state.taskProject || !state.taskTitle || !state.taskType) { + if (errorState.organization || errorState.project || errorState.type || errorState.fields.title) { + setTaskDetailsError(errorState); return; } - // Create payload to send in the POST request. - const payload = { - organization: state.taskOrganization, - project: state.taskProject, - type: state.taskType, - fields: { - title: state.taskTitle, - description: state.taskDescription, - }, - }; - - // TODO: save the payload in a state variable to use it while reading the state - // we can see later if there exists a better way for this - setTaskPayload(payload); - // Make POST api request - usePlugin.makeApiRequest(Constants.pluginApiServiceConfigs.createTask.apiServiceName, payload); - }, [state]); + createTask(); + }; + + // Make POST api request to create a task + const createTask = async () => { + const createTaskResponse = await usePlugin.makeApiRequest(Constants.pluginApiServiceConfigs.createTask.apiServiceName, taskDetails); + if (createTaskResponse) { + resetModalState(); + } + }; + // Set modal field values useEffect(() => { - if (taskPayload) { - const {isSuccess, isError} = usePlugin.getApiState(Constants.pluginApiServiceConfigs.createTask.apiServiceName, taskPayload); - if ((isSuccess && !isError) || (!isSuccess && isError)) { - onHide(); + if (getCreateTaskModalState(usePlugin.state).visibility) { + // Pre-select the dropdown value in case of single option. + if (organizationOptions.length === 1) { + setTaskDetails({...taskDetails, organization: organizationOptions[0].value}); } + if (projectOptions.length === 1) { + setTaskDetails({...taskDetails, project: projectOptions[0].value}); + } + + setTaskDetails({ + ...taskDetails, + fields: { + title: getCreateTaskModalState(usePlugin.state).commandArgs.title, + description: getCreateTaskModalState(usePlugin.state).commandArgs.description, + }, + }); } - }, [usePlugin.state]); - - if (visibility) { - return ( - - <> - onOrganizationChange(newValue)} - options={organizationOptions} - required={true} - error={taskOrganizationError} - /> - onProjectChange(newValue)} - options={projectOptions} - required={true} - error={taskProjectError} - /> - onTaskTypeChange(newValue)} - options={taskTypeOptions} - required={true} - error={taskTypeError} - /> - - - - - ); - } - return null; + }, [getCreateTaskModalState(usePlugin.state).visibility]); + + const apiResponse = usePlugin.getApiState(Constants.pluginApiServiceConfigs.createTask.apiServiceName, taskDetails); + return ( + + <> + + + + + + + + ); }; export default TaskModal; diff --git a/webapp/src/hooks/index.js b/webapp/src/hooks/index.js index 8973127b..2f298c6d 100644 --- a/webapp/src/hooks/index.js +++ b/webapp/src/hooks/index.js @@ -22,9 +22,12 @@ export default class Hooks { } if (commandTrimmed && commandTrimmed.startsWith('/azuredevops boards create')) { - // TODO: refactor - // const args = splitArgs(commandTrimmed); - return Promise.resolve({}); + const commandArgs = getCommandArgs(commandTrimmed); + this.store.dispatch(setGlobalModalState({modalId: 'createBoardTask', commandArgs})); + return Promise.resolve({ + message, + args: contextArgs, + }); } return Promise.resolve({ diff --git a/webapp/src/reducers/taskModal/index.ts b/webapp/src/reducers/taskModal/index.ts index 969bd53f..bdbdc020 100644 --- a/webapp/src/reducers/taskModal/index.ts +++ b/webapp/src/reducers/taskModal/index.ts @@ -1,26 +1,33 @@ -import {createSlice} from '@reduxjs/toolkit'; +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; -export interface CreateTaskModal { - visibility: boolean -} +import {getCreateTaskModalCommandArgs} from 'utils'; -const initialState: CreateTaskModal = { +const initialState: CreateTaskModalState = { visibility: false, + commandArgs: { + title: '', + description: '', + }, }; export const openTaskModalSlice = createSlice({ name: 'openTaskModal', initialState, reducers: { - showTaskModal: (state) => { - state.visibility = true; - }, - hideTaskModal: (state) => { - state.visibility = false; + toggleShowTaskModal: (state: CreateTaskModalState, action: PayloadAction) => { + state.visibility = action.payload.isVisible; + state.commandArgs.title = ''; + state.commandArgs.description = ''; + + if (action.payload.commandArgs.length > 1) { + const {title, description} = getCreateTaskModalCommandArgs(action.payload.commandArgs) as TaskFieldsCommandArgs; + state.commandArgs.title = title; + state.commandArgs.description = description; + } }, }, }); -export const {showTaskModal, hideTaskModal} = openTaskModalSlice.actions; +export const {toggleShowTaskModal} = openTaskModalSlice.actions; export default openTaskModalSlice.reducer; diff --git a/webapp/src/selectors/index.tsx b/webapp/src/selectors/index.tsx index c1c91fb5..a067afec 100644 --- a/webapp/src/selectors/index.tsx +++ b/webapp/src/selectors/index.tsx @@ -16,6 +16,10 @@ export const getLinkModalState = (state: any): LinkProjectModalState => { return state[pluginPrefix].openLinkModalSlice; }; +export const getCreateTaskModalState = (state: any): CreateTaskModalState => { + return state[pluginPrefix].openTaskModalReducer; +}; + export const getRhsState = (state: any): {isSidebarOpen: boolean} => { return state.views.rhs; }; diff --git a/webapp/src/types/common/store.d.ts b/webapp/src/types/common/store.d.ts index ccf44819..32e6d4b3 100644 --- a/webapp/src/types/common/store.d.ts +++ b/webapp/src/types/common/store.d.ts @@ -14,3 +14,13 @@ type LinkProjectModalState = { project: string, isLinked: boolean, } + +type TaskFieldsCommandArgs = { + title: string; + description: string; +} + +type CreateTaskModalState = { + visibility: boolean + commandArgs: TaskFieldsCommandArgs +} diff --git a/webapp/src/utils/index.ts b/webapp/src/utils/index.ts index a38446e1..c3b557ea 100644 --- a/webapp/src/utils/index.ts +++ b/webapp/src/utils/index.ts @@ -42,6 +42,20 @@ export const getProjectLinkModalArgs = (str: string): LinkPayload => { }; }; +export const getCreateTaskModalCommandArgs = (arr: Array): TaskFieldsCommandArgs => { + if (arr.length < 3) { + return { + title: '', + description: '', + }; + } + + return { + title: arr[1] ?? '', + description: arr[2] ?? '', + }; +}; + export const onPressingEnterKey = (event: Event | undefined, func: () => void) => { if (event instanceof KeyboardEvent && event.key !== 'Enter' && event.key !== ' ') { return; From ca65519aae70f02d2e95fb8dd8391828fc6ed7c1 Mon Sep 17 00:00:00 2001 From: ayusht2810 Date: Tue, 16 Aug 2022 23:16:23 +0530 Subject: [PATCH 21/28] [MI-2029_1] Update subscription modal --- webapp/src/components/modal/index.tsx | 4 +- .../src/containers/SubscribeModal/index.tsx | 95 +++++++++---------- 2 files changed, 48 insertions(+), 51 deletions(-) diff --git a/webapp/src/components/modal/index.tsx b/webapp/src/components/modal/index.tsx index 0b536dd0..d8abaf71 100644 --- a/webapp/src/components/modal/index.tsx +++ b/webapp/src/components/modal/index.tsx @@ -25,7 +25,7 @@ type ModalProps = { cancelDisabled?: boolean; } -const Modal = ({show, onHide, showCloseIconInHeader = true, children, title, subTitle, onConfirm, confirmAction, confirmBtnText, cancelBtnText, className = '', loading = false, error}: ModalProps) => { +const Modal = ({show, onHide, showCloseIconInHeader = true, children, title, subTitle, onConfirm, confirmAction, confirmBtnText, cancelBtnText, className = '', loading = false, error, confirmDisabled = false, cancelDisabled = false}: ModalProps) => { return ( diff --git a/webapp/src/containers/SubscribeModal/index.tsx b/webapp/src/containers/SubscribeModal/index.tsx index ffe4a41b..72e66168 100644 --- a/webapp/src/containers/SubscribeModal/index.tsx +++ b/webapp/src/containers/SubscribeModal/index.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {useDispatch, useSelector} from 'react-redux'; import {GlobalState} from 'mattermost-redux/types/store'; @@ -42,15 +42,12 @@ const SubscribeModal = () => { eventType: '', channelID: '', }); - const [subscriptionPayload, setSubscriptionPayload] = useState(); - const [loading, setLoading] = useState(false); - const [APIError, setAPIError] = useState(''); const [channelOptions, setChannelOptions] = useState([]); const [organizationOptions, setOrganizationOptions] = useState([]); const [projectOptions, setProjectOptions] = useState([]); const {entities} = useSelector((state: GlobalState) => state); const usePlugin = usePluginApi(); - const {visibility} = getSubscribeModalState(usePlugin.state); + const visibility = getSubscribeModalState(usePlugin.state).visibility; const dispatch = useDispatch(); // Get ProjectList State @@ -88,7 +85,7 @@ const SubscribeModal = () => { }, [projectOptions, organizationOptions, channelOptions]); // Function to hide the modal and reset all the states. - const onHide = useCallback(() => { + const onHide = () => { setSubscriptionDetails({ organization: '', project: '', @@ -101,67 +98,69 @@ const SubscribeModal = () => { eventType: '', channelID: '', }); - setLoading(false); - setAPIError(''); - setSubscriptionPayload(null); dispatch(toggleShowSubscribeModal({isVisible: false, commandArgs: []})); - }, []); + }; // Set organization name - const onOrganizationChange = useCallback((value: string) => { + const onOrganizationChange = (value: string) => { setErrorState({...errorState, organization: ''}); setSubscriptionDetails({...subscriptionDetails, organization: value}); - }, [subscriptionDetails, errorState]); + }; // Set project name - const onProjectChange = useCallback((value: string) => { + const onProjectChange = (value: string) => { setErrorState({...errorState, project: ''}); setSubscriptionDetails({...subscriptionDetails, project: value}); - }, [subscriptionDetails, errorState]); + }; // Set event type - const onEventTypeChange = useCallback((value: string) => { + const onEventTypeChange = (value: string) => { setErrorState({...errorState, eventType: ''}); setSubscriptionDetails({...subscriptionDetails, eventType: value}); - }, [subscriptionDetails, errorState]); + }; // Set channel name - const onChannelChange = useCallback((value: string) => { + const onChannelChange = (value: string) => { setErrorState({...errorState, channelID: ''}); setSubscriptionDetails({...subscriptionDetails, channelID: value}); - }, [subscriptionDetails, errorState]); + }; // Handles on confirming subscription - const onConfirm = useCallback(() => { + const onConfirm = () => { + const errorStateChanges: SubscriptionPayload = { + organization: '', + project: '', + channelID: '', + eventType: '', + }; if (subscriptionDetails.organization === '') { - setErrorState((value) => ({...value, organization: 'Organization is required'})); + errorStateChanges.organization = 'Organization is required'; } if (subscriptionDetails.project === '') { - setErrorState((value) => ({...value, project: 'Project is required'})); + errorStateChanges.project = 'Project is required'; } if (subscriptionDetails.eventType === '') { - setErrorState((value) => ({...value, eventType: 'Event type is required'})); + errorStateChanges.eventType = 'Event type is required'; } if (subscriptionDetails.channelID === '') { - setErrorState((value) => ({...value, channelID: 'Channel name is required'})); + errorStateChanges.channelID = 'Channel name is required'; } - if (!subscriptionDetails.organization || !subscriptionDetails.project || !subscriptionDetails.channelID || !subscriptionDetails.eventType) { + if (errorStateChanges.organization || errorStateChanges.project || errorStateChanges.channelID || errorStateChanges.eventType) { + setErrorState(errorStateChanges); return; } - // Create payload to send in the POST request. - const payload = { - organization: subscriptionDetails.organization, - project: subscriptionDetails.project, - channelID: subscriptionDetails.channelID, - eventType: subscriptionDetails.eventType, - }; - - setSubscriptionPayload(payload); - // Make POST api request - usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.createSubscription.apiServiceName, payload); - }, [subscriptionDetails]); + createSubscription(); + }; + + // Make POST api request to create subscription + const createSubscription = async () => { + const createSubscriptionResponse = await usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.createSubscription.apiServiceName, subscriptionDetails); + if (createSubscriptionResponse) { + onHide(); + } + }; useEffect(() => { const channelList = getChannelState().data; @@ -174,18 +173,10 @@ const SubscribeModal = () => { setProjectOptions(getProjectList(projectList)); setOrganizationOptions(getOrganizationList(projectList)); } - if (subscriptionPayload) { - const {isLoading, isSuccess, isError} = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.createSubscription.apiServiceName, subscriptionPayload); - setLoading(isLoading); - if (isSuccess) { - onHide(); - } - if (isError) { - setAPIError('Some error occurred. Please try again later.'); - } - } }, [usePlugin.state]); + const APIResponse = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.createSubscription.apiServiceName, subscriptionDetails); + return ( { onHide={onHide} onConfirm={onConfirm} confirmBtnText='Create subsciption' - cancelDisabled={loading} - confirmDisabled={loading} - loading={loading} - error={APIError} + confirmDisabled={APIResponse.isLoading} + cancelDisabled={APIResponse.isLoading} + loading={APIResponse.isLoading} + error={APIResponse.isError ? 'Some error occurred. Please try again later.' : ''} > <> { options={organizationOptions} required={true} error={errorState.organization} + disabled={APIResponse.isLoading} /> { options={projectOptions} required={true} error={errorState.project} + disabled={APIResponse.isLoading} /> { options={eventTypeOptions} required={true} error={errorState.eventType} + disabled={APIResponse.isLoading} /> { options={channelOptions} required={true} error={errorState.channelID} + disabled={APIResponse.isLoading} /> From ee631b60178885cc5331db3caca3243204d52944 Mon Sep 17 00:00:00 2001 From: ayusht2810 Date: Wed, 17 Aug 2022 12:34:03 +0530 Subject: [PATCH 22/28] [MI-2057] Integrate subscription list page --- server/plugin/api.go | 15 +- server/serializers/subscriptions.go | 1 + server/store/subscriptions.go | 1 + .../components/card/subscription/index.tsx | 11 +- webapp/src/containers/Rhs/index.tsx | 6 +- .../containers/Rhs/projectDetails/index.tsx | 167 ++++++++++++++---- .../src/containers/SubscribeModal/index.tsx | 8 +- webapp/src/plugin_constants/index.ts | 10 ++ webapp/src/reducers/subscribeModal/index.ts | 7 +- webapp/src/selectors/index.tsx | 2 +- webapp/src/services/index.ts | 15 ++ webapp/src/types/common/index.d.ts | 20 ++- webapp/src/types/common/store.d.ts | 1 + 13 files changed, 211 insertions(+), 53 deletions(-) diff --git a/server/plugin/api.go b/server/plugin/api.go index 4bfe1cc9..88925dc8 100644 --- a/server/plugin/api.go +++ b/server/plugin/api.go @@ -190,7 +190,12 @@ func (p *Plugin) handleUnlinkProject(w http.ResponseWriter, r *http.Request) { return } - if err := p.Store.DeleteProject(project); err != nil { + if err := p.Store.DeleteProject(&serializers.ProjectDetails{ + MattermostUserID: mattermostUserID, + ProjectID: project.ProjectID, + ProjectName: project.ProjectName, + OrganizationName: project.OrganizationName, + }); err != nil { p.API.LogError(constants.ErrorUnlinkProject, "Error", err.Error()) p.handleError(w, r, &serializers.Error{Code: http.StatusInternalServerError, Message: err.Error()}) } @@ -291,6 +296,13 @@ func (p *Plugin) handleCreateSubscriptions(w http.ResponseWriter, r *http.Reques return } + channel, channelErr := p.API.GetChannel(body.ChannelID) + if channelErr != nil { + p.API.LogError("Error in getting channels for team and user", "Error", channelErr.Error()) + http.Error(w, fmt.Sprintf("Error in getting channels for team and user. Error: %s", channelErr.Error()), channelErr.StatusCode) + return + } + p.Store.StoreSubscription(&serializers.SubscriptionDetails{ MattermostUserID: mattermostUserID, ProjectName: body.Project, @@ -299,6 +311,7 @@ func (p *Plugin) handleCreateSubscriptions(w http.ResponseWriter, r *http.Reques EventType: body.EventType, ChannelID: body.ChannelID, SubscriptionID: subscription.ID, + ChannelName: channel.DisplayName, }) response, err := json.Marshal(subscription) diff --git a/server/serializers/subscriptions.go b/server/serializers/subscriptions.go index cb99ce2f..875a6ab3 100644 --- a/server/serializers/subscriptions.go +++ b/server/serializers/subscriptions.go @@ -62,6 +62,7 @@ type SubscriptionDetails struct { OrganizationName string `json:"organizationName"` EventType string `json:"eventType"` ChannelID string `json:"channelID"` + ChannelName string `json:"channelName"` SubscriptionID string `json:"subscriptionID"` } diff --git a/server/store/subscriptions.go b/server/store/subscriptions.go index 74924e6b..58221a12 100644 --- a/server/store/subscriptions.go +++ b/server/store/subscriptions.go @@ -56,6 +56,7 @@ func (subscriptionList *SubscriptionList) AddSubscription(userID string, subscri ChannelID: subscription.ChannelID, EventType: subscription.EventType, SubscriptionID: subscription.SubscriptionID, + ChannelName: subscription.ChannelName, } subscriptionList.ByMattermostUserID[userID][subscriptionKey] = subscriptionListValue } diff --git a/webapp/src/components/card/subscription/index.tsx b/webapp/src/components/card/subscription/index.tsx index 91ce4be2..a30dff82 100644 --- a/webapp/src/components/card/subscription/index.tsx +++ b/webapp/src/components/card/subscription/index.tsx @@ -7,29 +7,34 @@ import LabelValuePair from 'components/labelValuePair'; import './styles.scss'; type SubscriptionCardProps = { + handleDeleteSubscrption: (subscriptionDetails: SubscriptionDetails) => void subscriptionDetails: SubscriptionDetails } -const SubscriptionCard = ({subscriptionDetails: {id, name, eventType}}: SubscriptionCardProps) => { +const SubscriptionCard = ({handleDeleteSubscrption, subscriptionDetails: {projectName, eventType, channelName}, subscriptionDetails}: SubscriptionCardProps) => { return (
-

{id}

+
handleDeleteSubscrption(subscriptionDetails)} />
diff --git a/webapp/src/containers/Rhs/index.tsx b/webapp/src/containers/Rhs/index.tsx index 9f0fc14b..af45e157 100644 --- a/webapp/src/containers/Rhs/index.tsx +++ b/webapp/src/containers/Rhs/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import usePluginApi from 'hooks/usePluginApi'; -import {getprojectDetailsState, getRhsState} from 'selectors'; +import {getProjectDetailsState, getRhsState} from 'selectors'; import AccountNotLinked from './accountNotLinked'; import ProjectList from './projectList'; @@ -21,8 +21,8 @@ const Rhs = (): JSX.Element => { } { usePlugin.isUserAccountConnected() && ( - getprojectDetailsState(usePlugin.state).projectID ? - : + getProjectDetailsState(usePlugin.state).projectID ? + : ) }
diff --git a/webapp/src/containers/Rhs/projectDetails/index.tsx b/webapp/src/containers/Rhs/projectDetails/index.tsx index cc1eda0a..8ae7cc62 100644 --- a/webapp/src/containers/Rhs/projectDetails/index.tsx +++ b/webapp/src/containers/Rhs/projectDetails/index.tsx @@ -1,68 +1,163 @@ -import React, {useEffect} from 'react'; +import React, {useEffect, useState} from 'react'; import {useDispatch} from 'react-redux'; import SubscriptionCard from 'components/card/subscription'; import IconButton from 'components/buttons/iconButton'; import BackButton from 'components/buttons/backButton'; +import LinearLoader from 'components/loader/linear'; +import ConfirmationModal from 'components/modal/confirmationModal'; import {resetProjectDetails} from 'reducers/projectDetails'; +import usePluginApi from 'hooks/usePluginApi'; +import plugin_constants from 'plugin_constants'; +import EmptyState from 'components/emptyState'; +import {toggleIsSubscribed, toggleShowSubscribeModal} from 'reducers/subscribeModal'; +import {getSubscribeModalState} from 'selectors'; -// TODO: dummy data, remove later -const data: SubscriptionDetails[] = [ - { - id: 'abc', - name: 'Listen for all new tasks created', - eventType: 'create', - }, - { - id: 'abc1', - name: 'Listen for any task updated', - eventType: 'update', - }, - { - id: 'abc2', - name: 'Listen for all any task deleted', - eventType: 'delete', - }, -]; - -type ProjectDetailsProps = { - title: string -} - -const ProjectDetails = ({title}: ProjectDetailsProps) => { +const ProjectDetails = (projectDetails: ProjectDetails) => { + // State variables + const [showProjectConfirmationModal, setShowProjectConfirmationModal] = useState(false); + const [showSubscriptionConfirmationModal, setShowSubscriptionConfirmationModal] = useState(false); + const [subscriptionToBeDeleted, setSubscriptionToBeDeleted] = useState(); + + // Hooks const dispatch = useDispatch(); + const usePlugin = usePluginApi(); const handleResetProjectDetails = () => { dispatch(resetProjectDetails()); }; + // Opens subscription modal + const handleSubscriptionModal = () => { + dispatch(toggleShowSubscribeModal({isVisible: true, commandArgs: []})); + }; + + // Opens a confirmation modal to confirm unlinking a project + const handleUnlinkProject = () => { + setShowProjectConfirmationModal(true); + }; + + // Opens a confirmation modal to confirm deletion of a subscription + const handleDeleteSubscription = (subscriptionDetails: SubscriptionDetails) => { + setSubscriptionToBeDeleted({ + organization: subscriptionDetails.organizationName, + project: subscriptionDetails.projectName, + eventType: subscriptionDetails.eventType, + channelID: subscriptionDetails.channelID, + }); + setShowSubscriptionConfirmationModal(true); + }; + + // Handles unlinking a project and fetching the modified project list + const handleConfirmUnlinkProject = async () => { + const unlinkProjectStatus = await usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.unlinkProject.apiServiceName, projectDetails); + + if (unlinkProjectStatus) { + handleResetProjectDetails(); + setShowProjectConfirmationModal(false); + } + }; + + // Handles deletion of a subscription and fetching the modified subscription list + const handleConfirmDeleteSubscription = async () => { + const deleteSubscriptionStatus = await usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.deleteSubscription.apiServiceName, subscriptionToBeDeleted); + + if (deleteSubscriptionStatus) { + fetchSubscriptionList(); + setShowSubscriptionConfirmationModal(false); + } + }; + + const project: FetchSubscriptionList = {project: projectDetails.projectName}; + const data = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getSubscriptionList.apiServiceName, project).data as SubscriptionDetails[]; + + // Fetch subscription list + const fetchSubscriptionList = () => usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.getSubscriptionList.apiServiceName, project); + // Reset the state when the component is unmounted useEffect(() => { - return handleResetProjectDetails(); + fetchSubscriptionList(); + return () => { + handleResetProjectDetails(); + }; }, []); + // Fetch the subscription list when new subscription is created + useEffect(() => { + if (getSubscribeModalState(usePlugin.state).isCreated) { + dispatch(toggleIsSubscribed(false)); + fetchSubscriptionList(); + } + }, [getSubscribeModalState(usePlugin.state).isCreated]); + return ( <> + setShowProjectConfirmationModal(false)} + onConfirm={handleConfirmUnlinkProject} + isLoading={usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.unlinkProject.apiServiceName, projectDetails).isLoading} + confirmBtnText='Unlink' + description={`Are you sure you want to unlink ${projectDetails.projectName}?`} + title='Confirm Project Unlink' + /> + setShowSubscriptionConfirmationModal(false)} + onConfirm={handleConfirmDeleteSubscription} + isLoading={usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.deleteSubscription.apiServiceName, subscriptionToBeDeleted).isLoading} + confirmBtnText='Delete' + description={`Are you sure you want to unsubscribe ${projectDetails.projectName} with event type ${subscriptionToBeDeleted?.eventType}?`} + title='Confirm Delete Subscription' + /> + { + usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getSubscriptionList.apiServiceName, project).isLoading && ( + + ) + }
-

{title}

+

{projectDetails.projectName}

handleUnlinkProject()} />
-
-

{'Subscriptions'}

-
{ - data.map((item) => ( - - ), + usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName).isSuccess && ( + data && data.length > 0 ? + <> +
+

{'Subscriptions'}

+
+ { + data.map((item) => ( + + ), + ) + } +
+ +
+ : + ) } diff --git a/webapp/src/containers/SubscribeModal/index.tsx b/webapp/src/containers/SubscribeModal/index.tsx index 72e66168..62555868 100644 --- a/webapp/src/containers/SubscribeModal/index.tsx +++ b/webapp/src/containers/SubscribeModal/index.tsx @@ -8,7 +8,7 @@ import Modal from 'components/modal'; import usePluginApi from 'hooks/usePluginApi'; import {getSubscribeModalState} from 'selectors'; import plugin_constants from 'plugin_constants'; -import {toggleShowSubscribeModal} from 'reducers/subscribeModal'; +import {toggleIsSubscribed, toggleShowSubscribeModal} from 'reducers/subscribeModal'; import Dropdown from 'components/dropdown'; import {getOrganizationList, getProjectList} from 'utils'; @@ -98,6 +98,9 @@ const SubscribeModal = () => { eventType: '', channelID: '', }); + setChannelOptions([]); + setOrganizationOptions([]); + setProjectOptions([]); dispatch(toggleShowSubscribeModal({isVisible: false, commandArgs: []})); }; @@ -158,6 +161,7 @@ const SubscribeModal = () => { const createSubscription = async () => { const createSubscriptionResponse = await usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.createSubscription.apiServiceName, subscriptionDetails); if (createSubscriptionResponse) { + dispatch(toggleIsSubscribed(true)); onHide(); } }; @@ -183,7 +187,7 @@ const SubscribeModal = () => { title='Create subscription' onHide={onHide} onConfirm={onConfirm} - confirmBtnText='Create subsciption' + confirmBtnText='Create subscription' confirmDisabled={APIResponse.isLoading} cancelDisabled={APIResponse.isLoading} loading={APIResponse.isLoading} diff --git a/webapp/src/plugin_constants/index.ts b/webapp/src/plugin_constants/index.ts index 1d1ab563..627012e8 100644 --- a/webapp/src/plugin_constants/index.ts +++ b/webapp/src/plugin_constants/index.ts @@ -53,6 +53,16 @@ const pluginApiServiceConfigs: Record = { method: 'GET', apiServiceName: 'getChannels', }, + getSubscriptionList: { + path: '/subscriptions?project=', + method: 'GET', + apiServiceName: 'getSubscriptionList', + }, + deleteSubscription: { + path: '/subscriptions', + method: 'DELETE', + apiServiceName: 'deleteSubscription', + }, }; export default { diff --git a/webapp/src/reducers/subscribeModal/index.ts b/webapp/src/reducers/subscribeModal/index.ts index 2dbae6ab..9c8f08b3 100644 --- a/webapp/src/reducers/subscribeModal/index.ts +++ b/webapp/src/reducers/subscribeModal/index.ts @@ -2,6 +2,7 @@ import {createSlice, PayloadAction} from '@reduxjs/toolkit'; const initialState: SubscribeModalState = { visibility: false, + isCreated: false, }; export const openSubscribeModalSlice = createSlice({ @@ -10,10 +11,14 @@ export const openSubscribeModalSlice = createSlice({ reducers: { toggleShowSubscribeModal: (state: SubscribeModalState, action: PayloadAction) => { state.visibility = action.payload.isVisible; + state.isCreated = false; + }, + toggleIsSubscribed: (state: SubscribeModalState, action: PayloadAction) => { + state.isCreated = action.payload; }, }, }); -export const {toggleShowSubscribeModal} = openSubscribeModalSlice.actions; +export const {toggleShowSubscribeModal, toggleIsSubscribed} = openSubscribeModalSlice.actions; export default openSubscribeModalSlice.reducer; diff --git a/webapp/src/selectors/index.tsx b/webapp/src/selectors/index.tsx index 86618930..ea674369 100644 --- a/webapp/src/selectors/index.tsx +++ b/webapp/src/selectors/index.tsx @@ -8,7 +8,7 @@ export const getGlobalModalState = (state: any): GlobalModalState => { return state[pluginPrefix].globalModalSlice; }; -export const getprojectDetailsState = (state: any) => { +export const getProjectDetailsState = (state: any) => { return state[pluginPrefix].projectDetailsSlice; }; diff --git a/webapp/src/services/index.ts b/webapp/src/services/index.ts index c31ce8be..3f72d336 100644 --- a/webapp/src/services/index.ts +++ b/webapp/src/services/index.ts @@ -64,6 +64,21 @@ const pluginApi = createApi({ method: Constants.pluginApiServiceConfigs.getChannels.method, }), }), + [Constants.pluginApiServiceConfigs.getSubscriptionList.apiServiceName]: builder.query({ + query: (params) => ({ + headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, + url: `${Constants.pluginApiServiceConfigs.getSubscriptionList.path}${params.project}`, + method: Constants.pluginApiServiceConfigs.getSubscriptionList.method, + }), + }), + [Constants.pluginApiServiceConfigs.deleteSubscription.apiServiceName]: builder.query({ + query: (payload) => ({ + headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, + url: Constants.pluginApiServiceConfigs.deleteSubscription.path, + method: Constants.pluginApiServiceConfigs.deleteSubscription.method, + body: payload, + }), + }), }), }); diff --git a/webapp/src/types/common/index.d.ts b/webapp/src/types/common/index.d.ts index b28b62e3..936f2e61 100644 --- a/webapp/src/types/common/index.d.ts +++ b/webapp/src/types/common/index.d.ts @@ -2,9 +2,9 @@ * Keep all common types here which are to be used throughout the project */ -type HttpMethod = 'GET' | 'POST'; +type HttpMethod = 'GET' | 'POST' | 'DELETE' ; -type ApiServiceName = 'createTask' | 'testGet' | 'createLink' | 'getAllLinkedProjectsList' | 'unlinkProject' | 'getUserDetails' | 'createSubscription' | 'getChannels' +type ApiServiceName = 'createTask' | 'testGet' | 'createLink' | 'getAllLinkedProjectsList' | 'unlinkProject' | 'getUserDetails' | 'createSubscription' | 'getChannels' | 'getSubscriptionList' | 'deleteSubscription' type PluginApiService = { path: string, @@ -45,7 +45,7 @@ type SubscriptionPayload = { channelID: string } -type APIRequestPayload = CreateTaskPayload | LinkPayload | ProjectDetails | UserDetails | SubscriptionPayload | FetchChannelParams | void; +type APIRequestPayload = CreateTaskPayload | LinkPayload | ProjectDetails | UserDetails | SubscriptionPayload | FetchChannelParams | FetchSubscriptionList | void; type DropdownOptionType = { label?: string | JSX.Element; @@ -81,12 +81,20 @@ type FetchChannelParams = { teamId: string; } +type FetchSubscriptionList = { + project: string; +} + type eventType = 'create' | 'update' | 'delete' type SubscriptionDetails = { - id: string - name: string - eventType: eventType + mattermostUserID: string + projectID: string, + projectName: string, + organizationName: string, + eventType: string, + channelID: string, + channelName: string, } type ModalId = 'linkProject' | 'createBoardTask' | 'subscribeProject' | null diff --git a/webapp/src/types/common/store.d.ts b/webapp/src/types/common/store.d.ts index f3654422..4bfca167 100644 --- a/webapp/src/types/common/store.d.ts +++ b/webapp/src/types/common/store.d.ts @@ -17,4 +17,5 @@ type LinkProjectModalState = { type SubscribeModalState = { visibility: boolean, + isCreated: boolean, } From c9b6257810d585b002fb472b671f87404aa30a28 Mon Sep 17 00:00:00 2001 From: ayusht2810 Date: Wed, 17 Aug 2022 16:56:47 +0530 Subject: [PATCH 23/28] Remove nolint comment --- server/plugin/client.go | 4 ++-- server/serializers/subscriptions.go | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/server/plugin/client.go b/server/plugin/client.go index 76a27517..5c8911ed 100644 --- a/server/plugin/client.go +++ b/server/plugin/client.go @@ -128,8 +128,8 @@ func (c *client) CreateSubscription(body *serializers.CreateSubscriptionRequestP payload := serializers.CreateSubscriptionBodyPayload{ PublisherID: constants.PublisherID, EventType: statusData[body.EventType], - ConsumerId: constants.ConsumerID, - ConsumerActionId: constants.ConsumerActionID, + ConsumerID: constants.ConsumerID, + ConsumerActionID: constants.ConsumerActionID, PublisherInputs: publisherInputs, ConsumerInputs: consumerInputs, } diff --git a/server/serializers/subscriptions.go b/server/serializers/subscriptions.go index 7232bf6e..5966149c 100644 --- a/server/serializers/subscriptions.go +++ b/server/serializers/subscriptions.go @@ -47,12 +47,10 @@ type CreateSubscriptionRequestPayload struct { } type CreateSubscriptionBodyPayload struct { - PublisherID string `json:"publisherId"` - EventType string `json:"eventType"` - // nolint // Disabling lint as json used is correct - ConsumerId string `json:"consumerId"` - // nolint - ConsumerActionId string `json:"consumerActionId"` + PublisherID string `json:"publisherId"` + EventType string `json:"eventType"` + ConsumerID string `json:"consumerId"` + ConsumerActionID string `json:"consumerActionId"` PublisherInputs PublisherInputs `json:"publisherInputs"` ConsumerInputs ConsumerInputs `json:"consumerInputs"` } From 7d359213ec0a008da1211a6f78248304df6c4038 Mon Sep 17 00:00:00 2001 From: Abhishek Verma Date: Wed, 17 Aug 2022 18:50:22 +0530 Subject: [PATCH 24/28] [MI-2060]: Refactor code for release and make required changes --- server/constants/constants.go | 13 ++- server/constants/messages.go | 10 +- server/plugin/client.go | 2 +- server/plugin/command.go | 14 +-- server/plugin/oAuth.go | 2 +- server/plugin/utils.go | 4 + .../components/card/subscription/index.tsx | 5 +- webapp/src/containers/TaskModal/index.tsx | 91 ++++++++++--------- webapp/src/plugin_constants/index.ts | 4 +- 9 files changed, 80 insertions(+), 65 deletions(-) diff --git a/server/constants/constants.go b/server/constants/constants.go index ec83d5ce..cac627e9 100644 --- a/server/constants/constants.go +++ b/server/constants/constants.go @@ -3,8 +3,8 @@ package constants const ( // Bot configs BotUsername = "azuredevops" - BotDisplayName = "Azure Devops" - BotDescription = "A bot account created by the Azure Devops plugin." + BotDisplayName = "Azure DevOps" + BotDescription = "A bot account created by the Azure DevOps plugin." // Plugin configs PluginID = "mattermost-plugin-azure-devops" @@ -13,8 +13,13 @@ const ( // Command configs CommandTriggerName = "azuredevops" - HelpText = "###### Mattermost Azure Devops Plugin - Slash Command Help\n" - InvalidCommand = "Invalid command parameters. Please use `/azuredevops help` for more information." + HelpText = "###### Mattermost Azure DevOps Plugin - Slash Command Help\n" + + "* `/azuredevops connect` - Connect your Mattermost account to your Azure DevOps account.\n" + + "* `/azuredevops disconnect` - Disconnect your Mattermost account from your Azure DevOps account.\n" + + "* `/azuredevops link [projectURL]` - Link your project to a current channel.\n" + + "* `/azuredevops boards create [title] [description]` - Create a new task for your project.\n" + + "* `/azuredevops subscribe` - Create subscriptions to track changes in tasks for your linked projects.\n" + InvalidCommand = "Invalid command parameters. Please use `/azuredevops help` for more information." // Get task link preview constants HTTPS = "https:" diff --git a/server/constants/messages.go b/server/constants/messages.go index 2be7e016..9e44f2ec 100644 --- a/server/constants/messages.go +++ b/server/constants/messages.go @@ -4,11 +4,11 @@ const ( // TODO: all these messages are to be verified from Mike at the end // Generic GenericErrorMessage = "Something went wrong, please try again later" - ConnectAccount = "[Click here to link your Azure DevOps account](%s%s)" - ConnectAccountFirst = "You do not have any Azure Devops account connected. Kindly link the account first" - UserConnected = "Your Azure Devops account is successfully connected!" - UserAlreadyConnected = "Your Azure Devops account is already connected" - UserDisconnected = "Your Azure Devops account is now disconnected" + ConnectAccount = "[Click here to connect your Azure DevOps account](%s%s)" + ConnectAccountFirst = "Your Azure DevOps account is not connected \n%s" + UserConnected = "Your Azure DevOps account is successfully connected!" + UserAlreadyConnected = "Your Azure DevOps account is already connected" + UserDisconnected = "Your Azure DevOps account is now disconnected" CreatedTask = "Link for newly created task: %s" TaskTitle = "[%s #%d: %s](%s)" TaskPreviewMessage = "State: %s\nAssigned To: %s\nDescription: %s" diff --git a/server/plugin/client.go b/server/plugin/client.go index 5c8911ed..13556398 100644 --- a/server/plugin/client.go +++ b/server/plugin/client.go @@ -174,7 +174,7 @@ func (c *client) callJSON(url, path, method, mattermostUserID string, in, out in // Makes HTTP request to REST APIs func (c *client) call(basePath, method, path, contentType string, mattermostUserID string, inBody io.Reader, out interface{}, formValues url.Values) (responseData []byte, statusCode int, err error) { - errContext := fmt.Sprintf("Azure Devops: Call failed: method:%s, path:%s", method, path) + errContext := fmt.Sprintf("Azure DevOps: Call failed: method:%s, path:%s", method, path) pathURL, err := url.Parse(path) if err != nil { return nil, http.StatusInternalServerError, errors.WithMessage(err, errContext) diff --git a/server/plugin/command.go b/server/plugin/command.go index df2fe80d..27e940de 100644 --- a/server/plugin/command.go +++ b/server/plugin/command.go @@ -55,12 +55,12 @@ func (p *Plugin) getAutoCompleteData() *model.AutocompleteData { disconnect := model.NewAutocompleteData("disconnect", "", "Disconnect your Azure DevOps account") azureDevops.AddCommand(disconnect) - create := model.NewAutocompleteData("boards create", "", "create a new task") - azureDevops.AddCommand(create) - - link := model.NewAutocompleteData("link", "[link]", "link a project") + link := model.NewAutocompleteData("link", "[projectURL]", "Link a project") azureDevops.AddCommand(link) + create := model.NewAutocompleteData("boards create [title] [description]", "", "Create a new task") + azureDevops.AddCommand(create) + subscribe := model.NewAutocompleteData("subscribe", "", "Add a subscription") azureDevops.AddCommand(subscribe) @@ -70,7 +70,7 @@ func (p *Plugin) getAutoCompleteData() *model.AutocompleteData { func (p *Plugin) getCommand() (*model.Command, error) { iconData, err := command.GetIconData(p.API, "assets/azurebot.svg") if err != nil { - return nil, errors.Wrap(err, "failed to get Azure Devops icon") + return nil, errors.Wrap(err, "failed to get Azure DevOps icon") } return &model.Command{ @@ -85,7 +85,7 @@ func (p *Plugin) getCommand() (*model.Command, error) { 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 p.sendEphemeralPostForCommand(commandArgs, p.getConnectAccountFirstMessage()) } return &model.CommandResponse{}, nil @@ -106,7 +106,7 @@ func azureDevopsConnectCommand(p *Plugin, c *plugin.Context, commandArgs *model. func azureDevopsDisconnectCommand(p *Plugin, c *plugin.Context, commandArgs *model.CommandArgs, args ...string) (*model.CommandResponse, *model.AppError) { message := constants.UserDisconnected if isConnected := p.UserAlreadyConnected(commandArgs.UserId); !isConnected { - message = constants.ConnectAccountFirst + message = p.getConnectAccountFirstMessage() } else { if isDeleted, err := p.Store.DeleteUser(commandArgs.UserId); !isDeleted { if err != nil { diff --git a/server/plugin/oAuth.go b/server/plugin/oAuth.go index e59071c4..eaac0473 100644 --- a/server/plugin/oAuth.go +++ b/server/plugin/oAuth.go @@ -241,7 +241,7 @@ func (p *Plugin) closeBrowserWindowWithHTTPResponse(w http.ResponseWriter) { -

Completed connecting to Azure Devops. Please close this window.

+

Completed connecting to Azure DevOps. Please close this window.

` diff --git a/server/plugin/utils.go b/server/plugin/utils.go index ed135da5..42860de3 100644 --- a/server/plugin/utils.go +++ b/server/plugin/utils.go @@ -191,3 +191,7 @@ func (p *Plugin) IsAnyProjectLinked(mattermostUserID string) (bool, error) { return true, nil } + +func (p *Plugin) getConnectAccountFirstMessage() string { + return fmt.Sprintf(constants.ConnectAccountFirst, fmt.Sprintf(constants.ConnectAccount, p.GetPluginURLPath(), constants.PathOAuthConnect)) +} diff --git a/webapp/src/components/card/subscription/index.tsx b/webapp/src/components/card/subscription/index.tsx index a30dff82..f7f2c8bd 100644 --- a/webapp/src/components/card/subscription/index.tsx +++ b/webapp/src/components/card/subscription/index.tsx @@ -16,10 +16,11 @@ const SubscriptionCard = ({handleDeleteSubscrption, subscriptionDetails: {projec
- + /> */} { description: '', }, }); + const [organizationOptions, setOrganizationOptions] = useState([]); + const [projectOptions, setProjectOptions] = useState([]); // Hooks const usePlugin = usePluginApi(); const dispatch = useDispatch(); + const {entities} = useSelector((state: GlobalState) => state); // Function to hide the modal and reset all the states. const resetModalState = () => { @@ -165,23 +139,54 @@ const TaskModal = () => { // Make POST api request to create a task const createTask = async () => { - const createTaskResponse = await usePlugin.makeApiRequest(Constants.pluginApiServiceConfigs.createTask.apiServiceName, taskDetails); + const createTaskResponse = await usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.createTask.apiServiceName, taskDetails); if (createTaskResponse) { resetModalState(); } }; + // Get ProjectList State + const getProjectState = () => { + const {isLoading, isSuccess, isError, data} = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName); + return {isLoading, isSuccess, isError, data: data as ProjectDetails[]}; + }; + + // Get ChannelList State + const getChannelState = () => { + const {isLoading, isSuccess, isError, data} = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getChannels.apiServiceName, {teamId: entities.teams.currentTeamId}); + return {isLoading, isSuccess, isError, data: data as ChannelList[]}; + }; + + useEffect(() => { + if (!getChannelState().data) { + usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.getChannels.apiServiceName, {teamId: entities.teams.currentTeamId}); + } + if (!getProjectState().data) { + usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName); + } + }, []); + + useEffect(() => { + const projectList = getProjectState().data; + if (projectList) { + setProjectOptions(getProjectList(projectList)); + setOrganizationOptions(getOrganizationList(projectList)); + } + }, [usePlugin.state]); + + useEffect(() => { + // Pre-select the dropdown value in case of single option. + if (organizationOptions.length === 1) { + setTaskDetails((value) => ({...value, organization: organizationOptions[0].value})); + } + if (projectOptions.length === 1) { + setTaskDetails((value) => ({...value, project: projectOptions[0].value})); + } + }, [projectOptions, organizationOptions]); + // Set modal field values useEffect(() => { if (getCreateTaskModalState(usePlugin.state).visibility) { - // Pre-select the dropdown value in case of single option. - if (organizationOptions.length === 1) { - setTaskDetails({...taskDetails, organization: organizationOptions[0].value}); - } - if (projectOptions.length === 1) { - setTaskDetails({...taskDetails, project: projectOptions[0].value}); - } - setTaskDetails({ ...taskDetails, fields: { @@ -192,7 +197,7 @@ const TaskModal = () => { } }, [getCreateTaskModalState(usePlugin.state).visibility]); - const apiResponse = usePlugin.getApiState(Constants.pluginApiServiceConfigs.createTask.apiServiceName, taskDetails); + const apiResponse = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.createTask.apiServiceName, taskDetails); return ( Date: Mon, 22 Aug 2022 14:37:46 +0530 Subject: [PATCH 25/28] [MI-2061]: Refactor code base and Explore and implement RTK error handling. --- server/constants/messages.go | 2 +- webapp/src/components/dropdown/index.tsx | 6 +- webapp/src/components/form/index.tsx | 37 +++ .../containers/Rhs/projectDetails/index.tsx | 5 +- .../src/containers/SubscribeModal/index.tsx | 283 +++++++----------- webapp/src/containers/TaskModal/index.tsx | 4 +- .../src/hooks/useApiRequestCompletionState.ts | 47 +++ webapp/src/hooks/useForm.ts | 121 ++++++++ webapp/src/hooks/usePluginApi.ts | 19 +- webapp/src/index.tsx | 8 +- webapp/src/plugin_constants/apiService.ts | 48 +++ webapp/src/plugin_constants/common.ts | 8 + webapp/src/plugin_constants/form.ts | 58 ++++ webapp/src/plugin_constants/index.ts | 87 ++---- webapp/src/plugin_constants/messages.ts | 6 + webapp/src/reducers/apiRequest/index.ts | 22 ++ webapp/src/reducers/index.ts | 4 +- webapp/src/reducers/subscribeModal/index.ts | 2 +- webapp/src/reducers/testReducer/index.ts | 36 --- webapp/src/selectors/index.tsx | 10 +- webapp/src/services/index.ts | 24 +- webapp/src/types/common/form.d.ts | 14 + webapp/src/types/common/index.d.ts | 57 +--- webapp/src/types/common/payload.d.ts | 18 ++ webapp/src/types/common/service.d.ts | 35 +++ webapp/src/types/common/store.d.ts | 13 + webapp/src/utils/errorHandling.ts | 24 ++ webapp/src/utils/index.ts | 10 +- 28 files changed, 652 insertions(+), 356 deletions(-) create mode 100644 webapp/src/components/form/index.tsx create mode 100644 webapp/src/hooks/useApiRequestCompletionState.ts create mode 100644 webapp/src/hooks/useForm.ts create mode 100644 webapp/src/plugin_constants/apiService.ts create mode 100644 webapp/src/plugin_constants/common.ts create mode 100644 webapp/src/plugin_constants/form.ts create mode 100644 webapp/src/plugin_constants/messages.ts create mode 100644 webapp/src/reducers/apiRequest/index.ts delete mode 100644 webapp/src/reducers/testReducer/index.ts create mode 100644 webapp/src/types/common/form.d.ts create mode 100644 webapp/src/types/common/payload.d.ts create mode 100644 webapp/src/types/common/service.d.ts create mode 100644 webapp/src/utils/errorHandling.ts diff --git a/server/constants/messages.go b/server/constants/messages.go index 9e44f2ec..c23ec24c 100644 --- a/server/constants/messages.go +++ b/server/constants/messages.go @@ -42,6 +42,6 @@ const ( CreateSubscriptionError = "Error in creating subscription" ProjectNotLinked = "Requested project is not linked" GetSubscriptionListError = "Error getting Subscription List" - SubscriptionAlreadyPresent = "Subscription is already present" + SubscriptionAlreadyPresent = "Requested subscription already exists" SubscriptionNotFound = "Requested subscription does not exists" ) diff --git a/webapp/src/components/dropdown/index.tsx b/webapp/src/components/dropdown/index.tsx index 7145bd0b..4bb6eef6 100644 --- a/webapp/src/components/dropdown/index.tsx +++ b/webapp/src/components/dropdown/index.tsx @@ -6,8 +6,8 @@ type DropdownProps = { value: string | null; placeholder: string; onChange: (newValue: string) => void; - options:DropdownOptionType[]; - customOption?: DropdownOptionType & { + options:LabelValuePair[]; + customOption?: LabelValuePair & { onClick: (customOptionValue: string) => void; } loadingOptions?: boolean; @@ -20,7 +20,7 @@ const Dropdown = ({value, placeholder, options, onChange, customOption, loadingO const [open, setOpen] = useState(false); // Handles closing the popover and updating the value when someone selects an option - const handleInputChange = (newOption: DropdownOptionType) => { + const handleInputChange = (newOption: LabelValuePair) => { setOpen(false); // Trigger onChange only if there is a change in the dropdown value diff --git a/webapp/src/components/form/index.tsx b/webapp/src/components/form/index.tsx new file mode 100644 index 00000000..e18c84ab --- /dev/null +++ b/webapp/src/components/form/index.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import Dropdown from 'components/dropdown'; + +type Props = { + fieldConfig: Pick + value: any + optionsList: any + onChange: (newValue: string) => void; + error?: string + isDisabled?: boolean +} + +/** + * A generic component to render form + * you can add multiple input field types here + */ +const Form = ({fieldConfig: {label, type, validations}, value, optionsList, onChange, error, isDisabled}: Props): JSX.Element => { + switch (type) { + case 'dropdown' : + return ( + + ); + default: + return <>; + } +}; + +export default Form; diff --git a/webapp/src/containers/Rhs/projectDetails/index.tsx b/webapp/src/containers/Rhs/projectDetails/index.tsx index e85cde3e..50afa4d2 100644 --- a/webapp/src/containers/Rhs/projectDetails/index.tsx +++ b/webapp/src/containers/Rhs/projectDetails/index.tsx @@ -73,7 +73,10 @@ const ProjectDetails = (projectDetails: ProjectDetails) => { const data = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getSubscriptionList.apiServiceName, project).data as SubscriptionDetails[]; // Fetch subscription list - const fetchSubscriptionList = () => usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.getSubscriptionList.apiServiceName, project); + const fetchSubscriptionList = () => usePlugin.makeApiRequest( + plugin_constants.pluginApiServiceConfigs.getSubscriptionList.apiServiceName, + project, + ); // Reset the state when the component is unmounted useEffect(() => { diff --git a/webapp/src/containers/SubscribeModal/index.tsx b/webapp/src/containers/SubscribeModal/index.tsx index 62555868..0a4046a4 100644 --- a/webapp/src/containers/SubscribeModal/index.tsx +++ b/webapp/src/containers/SubscribeModal/index.tsx @@ -4,172 +4,134 @@ import {useDispatch, useSelector} from 'react-redux'; import {GlobalState} from 'mattermost-redux/types/store'; import Modal from 'components/modal'; +import Form from 'components/form'; +import plugin_constants from 'plugin_constants'; + +import useApiRequestCompletionState from 'hooks/useApiRequestCompletionState'; import usePluginApi from 'hooks/usePluginApi'; +import useForm from 'hooks/useForm'; + +import {toggleShowSubscribeModal} from 'reducers/subscribeModal'; import {getSubscribeModalState} from 'selectors'; -import plugin_constants from 'plugin_constants'; -import {toggleIsSubscribed, toggleShowSubscribeModal} from 'reducers/subscribeModal'; -import Dropdown from 'components/dropdown'; -import {getOrganizationList, getProjectList} from 'utils'; + +import Utils, {getOrganizationList, getProjectList} from 'utils'; import './styles.scss'; const SubscribeModal = () => { - const eventTypeOptions = [ - { - value: 'create', - label: 'Create', - }, - { - value: 'update', - label: 'Update', - }, - { - value: 'delete', - label: 'Delete', - }, - ]; - - const [subscriptionDetails, setSubscriptionDetails] = useState({ - organization: '', - project: '', - eventType: '', - channelID: '', - }); - const [errorState, setErrorState] = useState({ - organization: '', - project: '', - eventType: '', - channelID: '', - }); - const [channelOptions, setChannelOptions] = useState([]); - const [organizationOptions, setOrganizationOptions] = useState([]); - const [projectOptions, setProjectOptions] = useState([]); - const {entities} = useSelector((state: GlobalState) => state); - const usePlugin = usePluginApi(); - const visibility = getSubscribeModalState(usePlugin.state).visibility; + // Hooks + const { + formFields, + errorState, + onChangeOfFormField, + setSpecificFieldValue, + resetFormFields, + isErrorInFormValidation, + } = useForm(plugin_constants.form.subscriptionModal); + const {getApiState, makeApiRequest, makeApiRequestWithCompletionStatus, state} = usePluginApi(); + const {visibility} = getSubscribeModalState(state); + const {entities} = useSelector((reduxState: GlobalState) => reduxState); const dispatch = useDispatch(); + // State variables + const [channelOptions, setChannelOptions] = useState([]); + const [organizationOptions, setOrganizationOptions] = useState([]); + const [projectOptions, setProjectOptions] = useState([]); + + // Function to hide the modal and reset all the states. + const resetModalState = (isActionDone?: boolean) => { + setChannelOptions([]); + setOrganizationOptions([]); + setProjectOptions([]); + resetFormFields(); + dispatch(toggleShowSubscribeModal({isVisible: false, commandArgs: [], isActionDone})); + }; + // Get ProjectList State const getProjectState = () => { - const {isLoading, isSuccess, isError, data} = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName); + const {isLoading, isSuccess, isError, data} = getApiState( + plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName, + ); return {isLoading, isSuccess, isError, data: data as ProjectDetails[]}; }; // Get ChannelList State const getChannelState = () => { - const {isLoading, isSuccess, isError, data} = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getChannels.apiServiceName, {teamId: entities.teams.currentTeamId}); + const {isLoading, isSuccess, isError, data} = getApiState( + plugin_constants.pluginApiServiceConfigs.getChannels.apiServiceName, + {teamId: entities.teams.currentTeamId}, + ); return {isLoading, isSuccess, isError, data: data as ChannelList[]}; }; + const getDropDownOptions = (fieldName: SubscriptionModalFields) => { + switch (fieldName) { + case 'organization': + return organizationOptions; + case 'project': + return projectOptions; + case 'eventType': + return plugin_constants.form.subscriptionModal.eventType.optionsList; + case 'channelID': + return channelOptions; + default: + return []; + } + }; + + // Handles on confirming create subscription + const onConfirm = () => { + if (!isErrorInFormValidation()) { + // Make POST api request to create subscription + makeApiRequestWithCompletionStatus( + plugin_constants.pluginApiServiceConfigs.createSubscription.apiServiceName, + formFields, + ); + } + }; + + // Observe for the change in redux state after API call and do the required actions + useApiRequestCompletionState({ + serviceName: plugin_constants.pluginApiServiceConfigs.createSubscription.apiServiceName, + handleSuccess: () => resetModalState(true), + payload: formFields, + }); + + // Make API request to fetch channel and project list useEffect(() => { if (!getChannelState().data) { - usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.getChannels.apiServiceName, {teamId: entities.teams.currentTeamId}); + makeApiRequest( + plugin_constants.pluginApiServiceConfigs.getChannels.apiServiceName, + {teamId: entities.teams.currentTeamId}, + ); } if (!getProjectState().data) { - usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName); + makeApiRequest(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName); } - }, []); + }, [visibility]); + // Pre-select the dropdown value in case of single option. useEffect(() => { - // Pre-select the dropdown value in case of single option. if (organizationOptions.length === 1) { - setSubscriptionDetails((value) => ({...value, organization: organizationOptions[0].value})); + setSpecificFieldValue('organization', organizationOptions[0].value); } if (projectOptions.length === 1) { - setSubscriptionDetails((value) => ({...value, project: projectOptions[0].value})); + setSpecificFieldValue('project', projectOptions[0].value); } if (channelOptions.length === 1) { - setSubscriptionDetails((value) => ({...value, channelID: channelOptions[0].value})); + setSpecificFieldValue('channelID', channelOptions[0].value); } }, [projectOptions, organizationOptions, channelOptions]); - // Function to hide the modal and reset all the states. - const onHide = () => { - setSubscriptionDetails({ - organization: '', - project: '', - eventType: '', - channelID: '', - }); - setErrorState({ - organization: '', - project: '', - eventType: '', - channelID: '', - }); - setChannelOptions([]); - setOrganizationOptions([]); - setProjectOptions([]); - dispatch(toggleShowSubscribeModal({isVisible: false, commandArgs: []})); - }; - - // Set organization name - const onOrganizationChange = (value: string) => { - setErrorState({...errorState, organization: ''}); - setSubscriptionDetails({...subscriptionDetails, organization: value}); - }; - - // Set project name - const onProjectChange = (value: string) => { - setErrorState({...errorState, project: ''}); - setSubscriptionDetails({...subscriptionDetails, project: value}); - }; - - // Set event type - const onEventTypeChange = (value: string) => { - setErrorState({...errorState, eventType: ''}); - setSubscriptionDetails({...subscriptionDetails, eventType: value}); - }; - - // Set channel name - const onChannelChange = (value: string) => { - setErrorState({...errorState, channelID: ''}); - setSubscriptionDetails({...subscriptionDetails, channelID: value}); - }; - - // Handles on confirming subscription - const onConfirm = () => { - const errorStateChanges: SubscriptionPayload = { - organization: '', - project: '', - channelID: '', - eventType: '', - }; - if (subscriptionDetails.organization === '') { - errorStateChanges.organization = 'Organization is required'; - } - if (subscriptionDetails.project === '') { - errorStateChanges.project = 'Project is required'; - } - if (subscriptionDetails.eventType === '') { - errorStateChanges.eventType = 'Event type is required'; - } - if (subscriptionDetails.channelID === '') { - errorStateChanges.channelID = 'Channel name is required'; - } - if (errorStateChanges.organization || errorStateChanges.project || errorStateChanges.channelID || errorStateChanges.eventType) { - setErrorState(errorStateChanges); - return; - } - - // Make POST api request - createSubscription(); - }; - - // Make POST api request to create subscription - const createSubscription = async () => { - const createSubscriptionResponse = await usePlugin.makeApiRequest(plugin_constants.pluginApiServiceConfigs.createSubscription.apiServiceName, subscriptionDetails); - if (createSubscriptionResponse) { - dispatch(toggleIsSubscribed(true)); - onHide(); - } - }; - + // Set channel and project list values useEffect(() => { const channelList = getChannelState().data; if (channelList) { - setChannelOptions(channelList.map((channel) => ({label: {channel.display_name}, value: channel.id}))); + setChannelOptions(channelList.map((channel) => ({ + label: {channel.display_name}, + value: channel.id, + }))); } const projectList = getProjectState().data; @@ -177,59 +139,36 @@ const SubscribeModal = () => { setProjectOptions(getProjectList(projectList)); setOrganizationOptions(getOrganizationList(projectList)); } - }, [usePlugin.state]); + }, [state]); - const APIResponse = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.createSubscription.apiServiceName, subscriptionDetails); + const {isLoading, isError, error} = getApiState(plugin_constants.pluginApiServiceConfigs.createSubscription.apiServiceName, formFields); return ( <> - onOrganizationChange(newValue)} - options={organizationOptions} - required={true} - error={errorState.organization} - disabled={APIResponse.isLoading} - /> - onProjectChange(newValue)} - options={projectOptions} - required={true} - error={errorState.project} - disabled={APIResponse.isLoading} - /> - onEventTypeChange(newValue)} - options={eventTypeOptions} - required={true} - error={errorState.eventType} - disabled={APIResponse.isLoading} - /> - onChannelChange(newValue)} - options={channelOptions} - required={true} - error={errorState.channelID} - disabled={APIResponse.isLoading} - /> + { + Object.keys(plugin_constants.form.subscriptionModal).map((field) => ( +
onChangeOfFormField(field as SubscriptionModalFields, newValue)} + error={errorState[field as SubscriptionModalFields]} + isDisabled={isLoading} + /> + )) + } ); diff --git a/webapp/src/containers/TaskModal/index.tsx b/webapp/src/containers/TaskModal/index.tsx index 6d99b84f..694df25c 100644 --- a/webapp/src/containers/TaskModal/index.tsx +++ b/webapp/src/containers/TaskModal/index.tsx @@ -48,8 +48,8 @@ const TaskModal = () => { description: '', }, }); - const [organizationOptions, setOrganizationOptions] = useState([]); - const [projectOptions, setProjectOptions] = useState([]); + const [organizationOptions, setOrganizationOptions] = useState([]); + const [projectOptions, setProjectOptions] = useState([]); // Hooks const usePlugin = usePluginApi(); diff --git a/webapp/src/hooks/useApiRequestCompletionState.ts b/webapp/src/hooks/useApiRequestCompletionState.ts new file mode 100644 index 00000000..f4b57824 --- /dev/null +++ b/webapp/src/hooks/useApiRequestCompletionState.ts @@ -0,0 +1,47 @@ +import {useEffect} from 'react'; +import {useDispatch} from 'react-redux'; + +import {resetApiRequestCompletionState} from 'reducers/apiRequest'; +import {getApiRequestCompletionState} from 'selectors'; + +import usePluginApi from './usePluginApi'; + +type Props = { + handleSuccess?: () => void + handleError?: () => void + serviceName: ApiServiceName + payload?: APIRequestPayload +} + +function useApiRequestCompletionState({handleSuccess, handleError, serviceName, payload}: Props) { + const {getApiState, state} = usePluginApi(); + const dispatch = useDispatch(); + + // Observe for the change in redux state after API call and do the required actions + useEffect(() => { + if ( + getApiRequestCompletionState(state).serviceName === serviceName && + getApiState(serviceName, payload) + ) { + const {isError, isSuccess, isUninitialized} = getApiState(serviceName, payload); + if (isSuccess && !isError) { + // eslint-disable-next-line no-unused-expressions + handleSuccess?.(); + } + + if (!isSuccess && isError) { + // eslint-disable-next-line no-unused-expressions + handleError?.(); + } + + if (!isUninitialized) { + dispatch(resetApiRequestCompletionState()); + } + } + }, [ + getApiRequestCompletionState(state).serviceName, + getApiState(serviceName, payload), + ]); +} + +export default useApiRequestCompletionState; diff --git a/webapp/src/hooks/useForm.ts b/webapp/src/hooks/useForm.ts new file mode 100644 index 00000000..64e39daf --- /dev/null +++ b/webapp/src/hooks/useForm.ts @@ -0,0 +1,121 @@ +import {useState} from 'react'; + +// Set initial value of form fields +const getInitialFieldsValue = ( + formFields: Record, +): Record => { + let fields = {}; + Object.keys(formFields).forEach((field) => { + fields = { + ...fields, + [field as FormFields]: + formFields[field as FormFields].value || + (field as FormFields === 'timestamp' ? Date.now().toString() : ''), + }; + }); + + return fields as unknown as Record; +}; + +/** + * Filter out all the fields for which validations check required + * and set empty string as default error message + */ +const getFieldslWhereErrorCheckRequired = ( + formFields: Record, +): Partial> => { + let fields = {}; + Object.keys(formFields).forEach((field) => { + if (formFields[field as FormFields].validations) { + fields = { + ...fields, + [field as FormFields]: '', + }; + } + }); + + return fields as unknown as Partial>; +}; + +// Check each type of validations and return required error message +const getValidationErrorMessage = ( + formFields: Record, + fieldName: FormFields, + fieldLabel: string, + validationType: ValidationTypes, +): string => { + switch (validationType) { + case 'isRequired': + return formFields[fieldName] ? '' : `${fieldLabel} is required`; + default: + return ''; + } +}; + +// Genric hook to handle form fields +function useForm(initialFormFields: Record) { + // Form field values + const [formFields, setFormFields] = useState(getInitialFieldsValue(initialFormFields)); + + // Form field error state + const [errorState, setErrorState] = useState>>( + getFieldslWhereErrorCheckRequired(initialFormFields), + ); + + /** + * Set new field value on change + * and reset field error state + */ + const onChangeOfFormField = (fieldName: FormFields, value: string) => { + setErrorState({...errorState, [fieldName]: ''}); + setFormFields({...formFields, [fieldName]: value}); + }; + + // Validate all form fields and set error if any + const isErrorInFormValidation = (): boolean => { + let fields = {}; + Object.keys(initialFormFields).forEach((field) => { + if (initialFormFields[field as FormFields].validations) { + Object.keys(initialFormFields[field as FormFields].validations ?? '').forEach((validation) => { + const validationMessage = getValidationErrorMessage( + formFields, + field as FormFields, + initialFormFields[field as FormFields].label, + validation as ValidationTypes, + ); + if (validationMessage) { + fields = { + ...fields, + [field]: validationMessage, + }; + } + }); + } + }); + + if (!Object.keys(fields).length) { + return false; + } + + setErrorState(fields); + return true; + }; + + // Reset form field values and error states + const resetFormFields = () => { + setFormFields(getInitialFieldsValue(initialFormFields)); + setErrorState(getFieldslWhereErrorCheckRequired(initialFormFields)); + }; + + // Set value for a specific form field + const setSpecificFieldValue = (fieldName: FormFields, value: string) => { + setFormFields({ + ...formFields, + [fieldName]: value, + }); + }; + + return {formFields, errorState, setSpecificFieldValue, onChangeOfFormField, isErrorInFormValidation, resetFormFields}; +} + +export default useForm; diff --git a/webapp/src/hooks/usePluginApi.ts b/webapp/src/hooks/usePluginApi.ts index c6ac19c5..2e45bc1f 100644 --- a/webapp/src/hooks/usePluginApi.ts +++ b/webapp/src/hooks/usePluginApi.ts @@ -1,6 +1,9 @@ import {useSelector, useDispatch} from 'react-redux'; + import {AnyAction} from 'redux'; +import {setApiRequestCompletionState} from 'reducers/apiRequest'; + import services from 'services'; function usePluginApi() { @@ -8,21 +11,29 @@ function usePluginApi() { 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): Promise | any => { + const makeApiRequest = async (serviceName: ApiServiceName, payload: APIRequestPayload): Promise | any => { return dispatch(services.endpoints[serviceName].initiate(payload)); //TODO: add proper type here }; + const makeApiRequestWithCompletionStatus = async (serviceName: ApiServiceName, payload: APIRequestPayload) => { + const apiRequest = await makeApiRequest(serviceName, payload); + + if (apiRequest) { + dispatch(setApiRequestCompletionState({serviceName})); + } + }; + // Pass payload only in POST rquests for GET requests there is no need to pass payload argument const getApiState = (serviceName: ApiServiceName, payload: APIRequestPayload) => { - const {data, isError, isLoading, isSuccess} = services.endpoints[serviceName].select(payload)(state['plugins-mattermost-plugin-azure-devops']); - return {data, isError, isLoading, isSuccess}; + const {data, isError, isLoading, isSuccess, error, isUninitialized} = services.endpoints[serviceName].select(payload)(state['plugins-mattermost-plugin-azure-devops']); + return {data, isError, isLoading, isSuccess, error, isUninitialized}; }; const isUserAccountConnected = (): boolean => { return state['plugins-mattermost-plugin-azure-devops'].userConnectedSlice.isConnected; }; - return {makeApiRequest, getApiState, state, isUserAccountConnected}; + return {makeApiRequest, makeApiRequestWithCompletionStatus, getApiState, state, isUserAccountConnected}; } export default usePluginApi; diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index cf1f9a1a..8a48a6d9 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -30,12 +30,12 @@ export default class Plugin { registry.registerRootComponent(TaskModal); registry.registerRootComponent(LinkModal); registry.registerRootComponent(SubscribeModal); - registry.registerWebSocketEventHandler(`custom_${Constants.pluginId}_connect`, handleConnect(store)); - registry.registerWebSocketEventHandler(`custom_${Constants.pluginId}_disconnect`, handleDisconnect(store)); - const {showRHSPlugin} = registry.registerRightHandSidebarComponent(Rhs, Constants.RightSidebarHeader); + registry.registerWebSocketEventHandler(`custom_${Constants.common.pluginId}_connect`, handleConnect(store)); + registry.registerWebSocketEventHandler(`custom_${Constants.common.pluginId}_disconnect`, handleDisconnect(store)); + const {showRHSPlugin} = registry.registerRightHandSidebarComponent(Rhs, Constants.common.RightSidebarHeader); const hooks = new Hooks(store); registry.registerSlashCommandWillBePostedHook(hooks.slashCommandWillBePostedHook); - registry.registerChannelHeaderButtonAction(, () => store.dispatch(showRHSPlugin), null, Constants.AzureDevops); + registry.registerChannelHeaderButtonAction(, () => store.dispatch(showRHSPlugin), null, Constants.common.AzureDevops); } } diff --git a/webapp/src/plugin_constants/apiService.ts b/webapp/src/plugin_constants/apiService.ts new file mode 100644 index 00000000..8e434cfc --- /dev/null +++ b/webapp/src/plugin_constants/apiService.ts @@ -0,0 +1,48 @@ +// Plugin api service (RTK query) configs +export const pluginApiServiceConfigs: Record = { + createTask: { + path: '/tasks', + method: 'POST', + apiServiceName: 'createTask', + }, + createLink: { + path: '/link', + method: 'POST', + apiServiceName: 'createLink', + }, + getAllLinkedProjectsList: { + path: '/project/link', + method: 'GET', + apiServiceName: 'getAllLinkedProjectsList', + }, + unlinkProject: { + path: '/project/unlink', + method: 'POST', + apiServiceName: 'unlinkProject', + }, + getUserDetails: { + path: '/user', + method: 'GET', + apiServiceName: 'getUserDetails', + }, + createSubscription: { + path: '/subscriptions', + method: 'POST', + apiServiceName: 'createSubscription', + }, + getChannels: { + path: '/channels', + method: 'GET', + apiServiceName: 'getChannels', + }, + getSubscriptionList: { + path: '/subscriptions?project=', + method: 'GET', + apiServiceName: 'getSubscriptionList', + }, + deleteSubscription: { + path: '/subscriptions', + method: 'DELETE', + apiServiceName: 'deleteSubscription', + }, +}; diff --git a/webapp/src/plugin_constants/common.ts b/webapp/src/plugin_constants/common.ts new file mode 100644 index 00000000..1e68f1cf --- /dev/null +++ b/webapp/src/plugin_constants/common.ts @@ -0,0 +1,8 @@ +// Plugin configs +export const pluginId = 'mattermost-plugin-azure-devops'; + +export const AzureDevops = 'Azure DevOps'; +export const RightSidebarHeader = 'Azure DevOps'; + +export const MMCSRF = 'MMCSRF'; +export const HeaderCSRFToken = 'X-CSRF-Token'; diff --git a/webapp/src/plugin_constants/form.ts b/webapp/src/plugin_constants/form.ts new file mode 100644 index 00000000..77b1d22e --- /dev/null +++ b/webapp/src/plugin_constants/form.ts @@ -0,0 +1,58 @@ +// Create subscription modal +const eventTypeOptions: LabelValuePair[] = [ + { + value: 'create', + label: 'Create', + }, + { + value: 'update', + label: 'Update', + }, + { + value: 'delete', + label: 'Delete', + }, +]; + +export const subscriptionModal: Record = { + organization: { + label: 'Organization name', + type: 'dropdown', + value: '', + validations: { + isRequired: true, + }, + }, + project: { + label: 'Project name', + value: '', + type: 'dropdown', + validations: { + isRequired: true, + }, + }, + eventType: { + label: 'Event type', + value: '', + type: 'dropdown', + optionsList: eventTypeOptions, + validations: { + isRequired: true, + }, + }, + channelID: { + label: 'Channel name', + value: '', + type: 'dropdown', + validations: { + isRequired: true, + }, + }, + + // add 'timestamp' field only if you don't want to use cached RTK Api query + timestamp: { + label: 'time', + type: 'timestamp', + value: '', + }, +}; diff --git a/webapp/src/plugin_constants/index.ts b/webapp/src/plugin_constants/index.ts index 4def1079..99142637 100644 --- a/webapp/src/plugin_constants/index.ts +++ b/webapp/src/plugin_constants/index.ts @@ -1,75 +1,30 @@ /** * Keep all plugin related constants here */ +import { + AzureDevops, + HeaderCSRFToken, + MMCSRF, + pluginId, + RightSidebarHeader, +} from './common'; +import {subscriptionModal} from './form'; +import {pluginApiServiceConfigs} from './apiService'; +import {error} from './messages'; -// Plugin configs -const pluginId = 'mattermost-plugin-azure-devops'; - -const AzureDevops = 'Azure DevOps'; -const RightSidebarHeader = 'Azure DevOps'; - -const MMCSRF = 'MMCSRF'; -const HeaderCSRFToken = 'X-CSRF-Token'; - -// Plugin api service (RTK query) configs -const pluginApiServiceConfigs: Record = { - createTask: { - path: '/tasks', - method: 'POST', - apiServiceName: 'createTask', - }, - createLink: { - path: '/link', - method: 'POST', - apiServiceName: 'createLink', - }, - testGet: { - path: '/test', - method: 'GET', - apiServiceName: 'testGet', - }, - getAllLinkedProjectsList: { - path: '/project/link', - method: 'GET', - apiServiceName: 'getAllLinkedProjectsList', - }, - unlinkProject: { - path: '/project/unlink', - method: 'POST', - apiServiceName: 'unlinkProject', - }, - getUserDetails: { - path: '/user', - method: 'GET', - apiServiceName: 'getUserDetails', - }, - createSubscription: { - path: '/subscriptions', - method: 'POST', - apiServiceName: 'createSubscription', - }, - getChannels: { - path: '/channels', - method: 'GET', - apiServiceName: 'getChannels', +export default { + common: { + pluginId, + MMCSRF, + HeaderCSRFToken, + AzureDevops, + RightSidebarHeader, }, - getSubscriptionList: { - path: '/subscriptions?project=', - method: 'GET', - apiServiceName: 'getSubscriptionList', + form: { + subscriptionModal, }, - deleteSubscription: { - path: '/subscriptions', - method: 'DELETE', - apiServiceName: 'deleteSubscription', + messages: { + error, }, -}; - -export default { - MMCSRF, - HeaderCSRFToken, - pluginId, pluginApiServiceConfigs, - AzureDevops, - RightSidebarHeader, }; diff --git a/webapp/src/plugin_constants/messages.ts b/webapp/src/plugin_constants/messages.ts new file mode 100644 index 00000000..5e1d2b85 --- /dev/null +++ b/webapp/src/plugin_constants/messages.ts @@ -0,0 +1,6 @@ +export const error = { + generic: 'Something went wrong, please try again later', + + // Subscription + subscriptionAlreadyExists: 'Requested subscription already exists', +}; diff --git a/webapp/src/reducers/apiRequest/index.ts b/webapp/src/reducers/apiRequest/index.ts new file mode 100644 index 00000000..73fc2957 --- /dev/null +++ b/webapp/src/reducers/apiRequest/index.ts @@ -0,0 +1,22 @@ +import {createSlice, PayloadAction} from '@reduxjs/toolkit'; + +const initialState: ApiRequestCompletionState = { + serviceName: '', +}; + +export const apiRequestCompletionSlice = createSlice({ + name: 'globalModal', + initialState, + reducers: { + setApiRequestCompletionState: (state: ApiRequestCompletionState, action: PayloadAction) => { + state.serviceName = action.payload.serviceName; + }, + resetApiRequestCompletionState: (state: ApiRequestCompletionState) => { + state.serviceName = ''; + }, + }, +}); + +export const {setApiRequestCompletionState, resetApiRequestCompletionState} = apiRequestCompletionSlice.actions; + +export default apiRequestCompletionSlice.reducer; diff --git a/webapp/src/reducers/index.ts b/webapp/src/reducers/index.ts index e46d41e7..f836006a 100644 --- a/webapp/src/reducers/index.ts +++ b/webapp/src/reducers/index.ts @@ -3,21 +3,21 @@ import {combineReducers} from 'redux'; import services from 'services'; import globalModalSlice from './globalModal'; +import apiRequestCompletionSlice from './apiRequest'; import openLinkModalSlice from './linkModal'; import openSubscribeModalSlice from './subscribeModal'; import openTaskModalReducer from './taskModal'; import projectDetailsSlice from './projectDetails'; import userConnectedSlice from './userConnected'; -import testReducer from './testReducer'; const reducers = combineReducers({ + apiRequestCompletionSlice, globalModalSlice, openLinkModalSlice, openTaskModalReducer, openSubscribeModalSlice, projectDetailsSlice, userConnectedSlice, - testReducer, [services.reducerPath]: services.reducer, }); diff --git a/webapp/src/reducers/subscribeModal/index.ts b/webapp/src/reducers/subscribeModal/index.ts index 9c8f08b3..1075d51d 100644 --- a/webapp/src/reducers/subscribeModal/index.ts +++ b/webapp/src/reducers/subscribeModal/index.ts @@ -11,7 +11,7 @@ export const openSubscribeModalSlice = createSlice({ reducers: { toggleShowSubscribeModal: (state: SubscribeModalState, action: PayloadAction) => { state.visibility = action.payload.isVisible; - state.isCreated = false; + state.isCreated = action.payload.isActionDone ?? false; }, toggleIsSubscribed: (state: SubscribeModalState, action: PayloadAction) => { state.isCreated = action.payload; diff --git a/webapp/src/reducers/testReducer/index.ts b/webapp/src/reducers/testReducer/index.ts deleted file mode 100644 index ac7f9da1..00000000 --- a/webapp/src/reducers/testReducer/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -// TODO: for reference of developers, remove when actual dev work is done - -import {createSlice, PayloadAction} from '@reduxjs/toolkit'; - -export interface CounterState { - value: number -} - -const initialState: CounterState = { - value: 0, -}; - -export const counterSlice = createSlice({ - name: 'counter', - initialState, - reducers: { - increment: (state: any) => { - // Redux Toolkit allows us to write "mutating" logic in reducers. It - // doesn't actually mutate the state because it uses the Immer library, - // which detects changes to a "draft state" and produces a brand new - // immutable state based off those changes - state.value += 1; - }, - decrement: (state: any) => { - state.value -= 1; - }, - incrementByAmount: (state: any, action: PayloadAction) => { - state.value += action.payload; - }, - }, -}); - -// Action creators are generated for each case reducer function -export const {increment, decrement, incrementByAmount} = counterSlice.actions; - -export default counterSlice.reducer; diff --git a/webapp/src/selectors/index.tsx b/webapp/src/selectors/index.tsx index b8078710..7919b26b 100644 --- a/webapp/src/selectors/index.tsx +++ b/webapp/src/selectors/index.tsx @@ -1,6 +1,6 @@ import plugin_constants from 'plugin_constants'; -const pluginPrefix = `plugins-${plugin_constants.pluginId}`; +const pluginPrefix = `plugins-${plugin_constants.common.pluginId}`; // TODO: create a type for global state @@ -27,3 +27,11 @@ export const getRhsState = (state: any): {isSidebarOpen: boolean} => { export const getSubscribeModalState = (state: any): SubscribeModalState => { return state[pluginPrefix].openSubscribeModalSlice; }; + +export const getApiRequestCompletionState = (state: any): ApiRequestCompletionState => { + return state[pluginPrefix].apiRequestCompletionSlice; +}; + +export const getApiQueriesState = (state: any): ApiQueriesState => { + return state[pluginPrefix].azureDevopsPluginApi?.queries; +}; diff --git a/webapp/src/services/index.ts b/webapp/src/services/index.ts index 3f72d336..062013d8 100644 --- a/webapp/src/services/index.ts +++ b/webapp/src/services/index.ts @@ -6,14 +6,14 @@ import Constants from 'plugin_constants'; import Utils from 'utils'; // Service to make plugin API requests -const pluginApi = createApi({ - reducerPath: 'pluginApi', +const azureDevopsPluginApi = createApi({ + reducerPath: 'azureDevopsPluginApi', baseQuery: fetchBaseQuery({baseUrl: Utils.getBaseUrls().pluginApiBaseUrl}), tagTypes: ['Posts'], endpoints: (builder) => ({ [Constants.pluginApiServiceConfigs.createTask.apiServiceName]: builder.query({ query: (payload) => ({ - headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, + headers: {[Constants.common.HeaderCSRFToken]: Cookies.get(Constants.common.MMCSRF)}, url: Constants.pluginApiServiceConfigs.createTask.path, method: Constants.pluginApiServiceConfigs.createTask.method, body: payload, @@ -21,7 +21,7 @@ const pluginApi = createApi({ }), [Constants.pluginApiServiceConfigs.createLink.apiServiceName]: builder.query({ query: (payload) => ({ - headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, + headers: {[Constants.common.HeaderCSRFToken]: Cookies.get(Constants.common.MMCSRF)}, url: Constants.pluginApiServiceConfigs.createLink.path, method: Constants.pluginApiServiceConfigs.createLink.method, body: payload, @@ -29,14 +29,14 @@ const pluginApi = createApi({ }), [Constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName]: builder.query({ query: () => ({ - headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, + headers: {[Constants.common.HeaderCSRFToken]: Cookies.get(Constants.common.MMCSRF)}, url: Constants.pluginApiServiceConfigs.getAllLinkedProjectsList.path, method: Constants.pluginApiServiceConfigs.getAllLinkedProjectsList.method, }), }), [Constants.pluginApiServiceConfigs.unlinkProject.apiServiceName]: builder.query({ query: (payload) => ({ - headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, + headers: {[Constants.common.HeaderCSRFToken]: Cookies.get(Constants.common.MMCSRF)}, url: Constants.pluginApiServiceConfigs.unlinkProject.path, method: Constants.pluginApiServiceConfigs.unlinkProject.method, body: payload, @@ -44,14 +44,14 @@ const pluginApi = createApi({ }), [Constants.pluginApiServiceConfigs.getUserDetails.apiServiceName]: builder.query({ query: () => ({ - headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, + headers: {[Constants.common.HeaderCSRFToken]: Cookies.get(Constants.common.MMCSRF)}, url: Constants.pluginApiServiceConfigs.getUserDetails.path, method: Constants.pluginApiServiceConfigs.getUserDetails.method, }), }), [Constants.pluginApiServiceConfigs.createSubscription.apiServiceName]: builder.query({ query: (payload) => ({ - headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, + headers: {[Constants.common.HeaderCSRFToken]: Cookies.get(Constants.common.MMCSRF)}, url: Constants.pluginApiServiceConfigs.createSubscription.path, method: Constants.pluginApiServiceConfigs.createSubscription.method, body: payload, @@ -59,21 +59,21 @@ const pluginApi = createApi({ }), [Constants.pluginApiServiceConfigs.getChannels.apiServiceName]: builder.query({ query: (params) => ({ - headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, + headers: {[Constants.common.HeaderCSRFToken]: Cookies.get(Constants.common.MMCSRF)}, url: `${Constants.pluginApiServiceConfigs.getChannels.path}/${params.teamId}`, method: Constants.pluginApiServiceConfigs.getChannels.method, }), }), [Constants.pluginApiServiceConfigs.getSubscriptionList.apiServiceName]: builder.query({ query: (params) => ({ - headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, + headers: {[Constants.common.HeaderCSRFToken]: Cookies.get(Constants.common.MMCSRF)}, url: `${Constants.pluginApiServiceConfigs.getSubscriptionList.path}${params.project}`, method: Constants.pluginApiServiceConfigs.getSubscriptionList.method, }), }), [Constants.pluginApiServiceConfigs.deleteSubscription.apiServiceName]: builder.query({ query: (payload) => ({ - headers: {[Constants.HeaderCSRFToken]: Cookies.get(Constants.MMCSRF)}, + headers: {[Constants.common.HeaderCSRFToken]: Cookies.get(Constants.common.MMCSRF)}, url: Constants.pluginApiServiceConfigs.deleteSubscription.path, method: Constants.pluginApiServiceConfigs.deleteSubscription.method, body: payload, @@ -82,4 +82,4 @@ const pluginApi = createApi({ }), }); -export default pluginApi; +export default azureDevopsPluginApi; diff --git a/webapp/src/types/common/form.d.ts b/webapp/src/types/common/form.d.ts new file mode 100644 index 00000000..71c306f3 --- /dev/null +++ b/webapp/src/types/common/form.d.ts @@ -0,0 +1,14 @@ +type FieldType = 'dropdown' | 'text' | 'timestamp' +type ValidationTypes = 'isRequired' | 'maxCharLen' | 'minCharLen' | 'regex' | 'regexErrorMessage' +type SubscriptionModalFields = 'organization' | 'project' | 'eventType' | 'channelID' | 'timestamp' +type ErrorComponents = 'SubscribeModal' + +type ModalFormFieldConfig = { + label: string + value: string + type: FieldType, + optionsList?: LabelValuePair[] + validations?: Partial> +} + +type FormFields = SubscriptionModalFields diff --git a/webapp/src/types/common/index.d.ts b/webapp/src/types/common/index.d.ts index eda8e1ab..74f10322 100644 --- a/webapp/src/types/common/index.d.ts +++ b/webapp/src/types/common/index.d.ts @@ -1,60 +1,27 @@ /** * Keep all common types here which are to be used throughout the project */ - -type HttpMethod = 'GET' | 'POST' | 'DELETE' ; - -type ApiServiceName = 'createTask' | 'testGet' | 'createLink' | 'getAllLinkedProjectsList' | 'unlinkProject' | 'getUserDetails' | 'createSubscription' | 'getChannels' | 'getSubscriptionList' | 'deleteSubscription' - -type PluginApiService = { - path: string, - method: HttpMethod, - apiServiceName: ApiServiceName -} - -type PluginState = { - 'plugins-mattermost-plugin-azure-devops': RootState<{ [x: string]: QueryDefinition, never, WellList[], 'pluginApi'>; }, never, 'pluginApi'> -} +type eventType = 'create' | 'update' | 'delete' +type ModalId = 'linkProject' | 'createBoardTask' | 'subscribeProject' | null type TabData = { title: string, tabPanel: JSX.Element } -type CreateTaskFields = { - title: string, - description: string, -} - -type LinkPayload = { - organization: string, - project: string, -} - -type CreateTaskPayload = { - organization: string, - project: string, - type: string, - fields: CreateTaskFields, -} - -type SubscriptionPayload = { - organization: string, - project: string, - eventType: string, - channelID: string +type TabsData = { + title: string + component: JSX.Element } -type APIRequestPayload = CreateTaskPayload | LinkPayload | ProjectDetails | UserDetails | SubscriptionPayload | FetchChannelParams | FetchSubscriptionList | void; - -type DropdownOptionType = { - label?: string | JSX.Element; +type LabelValuePair = { + label: string | JSX.Element; value: string; } -type TabsData = { - title: string - component: JSX.Element +type CreateTaskFields = { + title: string, + description: string, } type ProjectDetails = { @@ -85,8 +52,6 @@ type FetchSubscriptionList = { project: string; } -type eventType = 'create' | 'update' | 'delete' - type SubscriptionDetails = { mattermostUserID: string projectID: string, @@ -96,5 +61,3 @@ type SubscriptionDetails = { channelID: string, channelName: string, } - -type ModalId = 'linkProject' | 'createBoardTask' | 'subscribeProject' | null diff --git a/webapp/src/types/common/payload.d.ts b/webapp/src/types/common/payload.d.ts new file mode 100644 index 00000000..d89adbef --- /dev/null +++ b/webapp/src/types/common/payload.d.ts @@ -0,0 +1,18 @@ +type LinkPayload = { + organization: string, + project: string, +} + +type CreateTaskPayload = { + organization: string, + project: string, + type: string, + fields: CreateTaskFields, +} + +type SubscriptionPayload = { + organization: string, + project: string, + eventType: string, + channelID: string +} diff --git a/webapp/src/types/common/service.d.ts b/webapp/src/types/common/service.d.ts new file mode 100644 index 00000000..c0c4f93f --- /dev/null +++ b/webapp/src/types/common/service.d.ts @@ -0,0 +1,35 @@ +type HttpMethod = 'GET' | 'POST' | 'DELETE' ; + +type ApiServiceName = + 'createTask' | + 'createLink' | + 'getAllLinkedProjectsList' | + 'unlinkProject' | + 'getUserDetails' | + 'createSubscription' | + 'getChannels' | + 'getSubscriptionList' | + 'deleteSubscription' + +type PluginApiService = { + path: string, + method: HttpMethod, + apiServiceName: ApiServiceName +} + +type ApiErrorResponse = { + data: { + error: string + }, + status: number +} + +type APIRequestPayload = + CreateTaskPayload | + LinkPayload | + ProjectDetails | + UserDetails | + SubscriptionPayload | + FetchChannelParams | + FetchSubscriptionList | + void; diff --git a/webapp/src/types/common/store.d.ts b/webapp/src/types/common/store.d.ts index 3438ee51..61ffda78 100644 --- a/webapp/src/types/common/store.d.ts +++ b/webapp/src/types/common/store.d.ts @@ -1,3 +1,11 @@ +type PluginState = { + 'plugins-mattermost-plugin-azure-devops': RootState<{ [x: string]: QueryDefinition, never, WellList[], 'azureDevopsPluginApi'>; }, never, 'pluginApi'> +} + +type ApiRequestCompletionState = { + serviceName: string +} + type GlobalModalState = { modalId: ModalId commandArgs: Array @@ -6,6 +14,7 @@ type GlobalModalState = { type GlobalModalActionPayload = { isVisible: boolean commandArgs: Array + isActionDone?: boolean } type LinkProjectModalState = { @@ -29,3 +38,7 @@ type CreateTaskModalState = { visibility: boolean commandArgs: TaskFieldsCommandArgs } + +type ApiQueriesState = { + [key: string]: Record +} diff --git a/webapp/src/utils/errorHandling.ts b/webapp/src/utils/errorHandling.ts new file mode 100644 index 00000000..88baaac3 --- /dev/null +++ b/webapp/src/utils/errorHandling.ts @@ -0,0 +1,24 @@ +import plugin_constants from 'plugin_constants'; + +const getErrorMessage = ( + isError: boolean, + component: ErrorComponents, + errorState: ApiErrorResponse, +): string => { + if (!isError) { + return ''; + } + + switch (component) { + case 'SubscribeModal': // Create subscription modal + if (errorState.status === 400 && errorState.data.error === plugin_constants.messages.error.subscriptionAlreadyExists) { + return errorState.data.error; + } + return plugin_constants.messages.error.generic; + + default: + return plugin_constants.messages.error.generic; + } +}; + +export default getErrorMessage; diff --git a/webapp/src/utils/index.ts b/webapp/src/utils/index.ts index 24af8006..0f39967e 100644 --- a/webapp/src/utils/index.ts +++ b/webapp/src/utils/index.ts @@ -1,13 +1,14 @@ /** * Utils */ - import Constants from 'plugin_constants'; +import getErrorMessage from './errorHandling'; + const getBaseUrls = (): {pluginApiBaseUrl: string; mattermostApiBaseUrl: string} => { const url = new URL(window.location.href); const baseUrl = `${url.protocol}//${url.host}`; - const pluginUrl = `${baseUrl}/plugins/${Constants.pluginId}`; + const pluginUrl = `${baseUrl}/plugins/${Constants.common.pluginId}`; const pluginApiBaseUrl = `${pluginUrl}/api/v1`; const mattermostApiBaseUrl = `${baseUrl}/api/v4`; @@ -65,14 +66,14 @@ export const onPressingEnterKey = (event: Event | undefined, func: () => void) = }; export const getProjectList = (data: ProjectDetails[]) => { - const projectList: DropdownOptionType[] = []; + const projectList: LabelValuePair[] = []; data.map((project) => projectList.push({value: project.projectName, label: project.projectName})); return projectList; }; export const getOrganizationList = (data: ProjectDetails[]) => { const uniqueOrganization: Record = {}; - const organizationList: DropdownOptionType[] = []; + const organizationList: LabelValuePair[] = []; data.map((organization) => { if (!(organization.organizationName in uniqueOrganization)) { uniqueOrganization[organization.organizationName] = true; @@ -85,4 +86,5 @@ export const getOrganizationList = (data: ProjectDetails[]) => { export default { getBaseUrls, + getErrorMessage, }; From 86d00f805a70398a569c22fb87734dfe2bbe554b Mon Sep 17 00:00:00 2001 From: Abhishek Verma Date: Mon, 22 Aug 2022 16:04:35 +0530 Subject: [PATCH 26/28] [MI-2061]: Added logic to handle error/success for multiple API calls at a time --- webapp/src/hooks/useApiRequestCompletionState.ts | 6 +++--- webapp/src/hooks/usePluginApi.ts | 2 +- webapp/src/reducers/apiRequest/index.ts | 10 +++++----- webapp/src/types/common/store.d.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/webapp/src/hooks/useApiRequestCompletionState.ts b/webapp/src/hooks/useApiRequestCompletionState.ts index f4b57824..0342a975 100644 --- a/webapp/src/hooks/useApiRequestCompletionState.ts +++ b/webapp/src/hooks/useApiRequestCompletionState.ts @@ -20,7 +20,7 @@ function useApiRequestCompletionState({handleSuccess, handleError, serviceName, // Observe for the change in redux state after API call and do the required actions useEffect(() => { if ( - getApiRequestCompletionState(state).serviceName === serviceName && + getApiRequestCompletionState(state).requestes.includes(serviceName) && getApiState(serviceName, payload) ) { const {isError, isSuccess, isUninitialized} = getApiState(serviceName, payload); @@ -35,11 +35,11 @@ function useApiRequestCompletionState({handleSuccess, handleError, serviceName, } if (!isUninitialized) { - dispatch(resetApiRequestCompletionState()); + dispatch(resetApiRequestCompletionState(serviceName)); } } }, [ - getApiRequestCompletionState(state).serviceName, + getApiRequestCompletionState(state).requestes.includes(serviceName), getApiState(serviceName, payload), ]); } diff --git a/webapp/src/hooks/usePluginApi.ts b/webapp/src/hooks/usePluginApi.ts index 2e45bc1f..f2b7a375 100644 --- a/webapp/src/hooks/usePluginApi.ts +++ b/webapp/src/hooks/usePluginApi.ts @@ -19,7 +19,7 @@ function usePluginApi() { const apiRequest = await makeApiRequest(serviceName, payload); if (apiRequest) { - dispatch(setApiRequestCompletionState({serviceName})); + dispatch(setApiRequestCompletionState(serviceName)); } }; diff --git a/webapp/src/reducers/apiRequest/index.ts b/webapp/src/reducers/apiRequest/index.ts index 73fc2957..daaaf49c 100644 --- a/webapp/src/reducers/apiRequest/index.ts +++ b/webapp/src/reducers/apiRequest/index.ts @@ -1,18 +1,18 @@ import {createSlice, PayloadAction} from '@reduxjs/toolkit'; const initialState: ApiRequestCompletionState = { - serviceName: '', + requestes: [], }; export const apiRequestCompletionSlice = createSlice({ name: 'globalModal', initialState, reducers: { - setApiRequestCompletionState: (state: ApiRequestCompletionState, action: PayloadAction) => { - state.serviceName = action.payload.serviceName; + setApiRequestCompletionState: (state: ApiRequestCompletionState, action: PayloadAction) => { + state.requestes = [...state.requestes, action.payload]; }, - resetApiRequestCompletionState: (state: ApiRequestCompletionState) => { - state.serviceName = ''; + resetApiRequestCompletionState: (state: ApiRequestCompletionState, action: PayloadAction) => { + state.requestes = state.requestes.filter(((request) => request !== action.payload)); }, }, }); diff --git a/webapp/src/types/common/store.d.ts b/webapp/src/types/common/store.d.ts index 61ffda78..ed3ae67f 100644 --- a/webapp/src/types/common/store.d.ts +++ b/webapp/src/types/common/store.d.ts @@ -3,7 +3,7 @@ type PluginState = { } type ApiRequestCompletionState = { - serviceName: string + requestes: ApiServiceName[] } type GlobalModalState = { From 99ce6344b5345d75a2c7035eeff4036281342d92 Mon Sep 17 00:00:00 2001 From: Abhishek Verma Date: Mon, 5 Sep 2022 13:24:55 +0530 Subject: [PATCH 27/28] [MI-2061]: Review fix --- webapp/src/components/dropdown/index.tsx | 6 +- .../src/containers/SubscribeModal/index.tsx | 22 +++--- .../src/hooks/useApiRequestCompletionState.ts | 4 +- webapp/src/hooks/useForm.ts | 74 +++++++++---------- webapp/src/hooks/usePluginApi.ts | 2 +- webapp/src/plugin_constants/common.ts | 1 - webapp/src/plugin_constants/form.ts | 2 +- webapp/src/reducers/apiRequest/index.ts | 6 +- webapp/src/selectors/index.tsx | 2 +- webapp/src/services/index.ts | 6 +- webapp/src/types/common/form.d.ts | 2 +- webapp/src/types/common/store.d.ts | 4 +- 12 files changed, 66 insertions(+), 65 deletions(-) diff --git a/webapp/src/components/dropdown/index.tsx b/webapp/src/components/dropdown/index.tsx index 4bb6eef6..ca7a8fba 100644 --- a/webapp/src/components/dropdown/index.tsx +++ b/webapp/src/components/dropdown/index.tsx @@ -6,7 +6,7 @@ type DropdownProps = { value: string | null; placeholder: string; onChange: (newValue: string) => void; - options:LabelValuePair[]; + options: LabelValuePair[]; customOption?: LabelValuePair & { onClick: (customOptionValue: string) => void; } @@ -16,7 +16,7 @@ type DropdownProps = { error?: boolean | string; } -const Dropdown = ({value, placeholder, options, onChange, customOption, loadingOptions, disabled, error, required}: DropdownProps): JSX.Element => { +const Dropdown = ({value, placeholder, options, onChange, customOption, loadingOptions, disabled = false, error='', required}: DropdownProps): JSX.Element => { const [open, setOpen] = useState(false); // Handles closing the popover and updating the value when someone selects an option @@ -33,7 +33,7 @@ const Dropdown = ({value, placeholder, options, onChange, customOption, loadingO const handleCustomOptionClick = () => { // Update the value on the input to indicate custom options has been chosen handleInputChange({ - label: customOption?.label, + label: customOption?.label ?? '', value: customOption?.value as string, }); diff --git a/webapp/src/containers/SubscribeModal/index.tsx b/webapp/src/containers/SubscribeModal/index.tsx index 0a4046a4..d9088856 100644 --- a/webapp/src/containers/SubscribeModal/index.tsx +++ b/webapp/src/containers/SubscribeModal/index.tsx @@ -20,18 +20,20 @@ import Utils, {getOrganizationList, getProjectList} from 'utils'; import './styles.scss'; const SubscribeModal = () => { + const {subscriptionModal} = plugin_constants.form + // Hooks const { formFields, errorState, - onChangeOfFormField, + onChangeFormField, setSpecificFieldValue, resetFormFields, isErrorInFormValidation, - } = useForm(plugin_constants.form.subscriptionModal); + } = useForm(subscriptionModal); const {getApiState, makeApiRequest, makeApiRequestWithCompletionStatus, state} = usePluginApi(); const {visibility} = getSubscribeModalState(state); - const {entities} = useSelector((reduxState: GlobalState) => reduxState); + const {currentTeamId} = useSelector((reduxState: GlobalState) => reduxState.entities.teams); const dispatch = useDispatch(); // State variables @@ -60,7 +62,7 @@ const SubscribeModal = () => { const getChannelState = () => { const {isLoading, isSuccess, isError, data} = getApiState( plugin_constants.pluginApiServiceConfigs.getChannels.apiServiceName, - {teamId: entities.teams.currentTeamId}, + {teamId: currentTeamId}, ); return {isLoading, isSuccess, isError, data: data as ChannelList[]}; }; @@ -72,7 +74,7 @@ const SubscribeModal = () => { case 'project': return projectOptions; case 'eventType': - return plugin_constants.form.subscriptionModal.eventType.optionsList; + return subscriptionModal.eventType.optionsList; case 'channelID': return channelOptions; default: @@ -103,7 +105,7 @@ const SubscribeModal = () => { if (!getChannelState().data) { makeApiRequest( plugin_constants.pluginApiServiceConfigs.getChannels.apiServiceName, - {teamId: entities.teams.currentTeamId}, + {teamId: currentTeamId}, ); } if (!getProjectState().data) { @@ -157,13 +159,13 @@ const SubscribeModal = () => { > <> { - Object.keys(plugin_constants.form.subscriptionModal).map((field) => ( + Object.keys(subscriptionModal).map((field) => ( onChangeOfFormField(field as SubscriptionModalFields, newValue)} + onChange={(newValue) => onChangeFormField(field as SubscriptionModalFields, newValue)} error={errorState[field as SubscriptionModalFields]} isDisabled={isLoading} /> diff --git a/webapp/src/hooks/useApiRequestCompletionState.ts b/webapp/src/hooks/useApiRequestCompletionState.ts index 0342a975..215b0ad3 100644 --- a/webapp/src/hooks/useApiRequestCompletionState.ts +++ b/webapp/src/hooks/useApiRequestCompletionState.ts @@ -20,7 +20,7 @@ function useApiRequestCompletionState({handleSuccess, handleError, serviceName, // Observe for the change in redux state after API call and do the required actions useEffect(() => { if ( - getApiRequestCompletionState(state).requestes.includes(serviceName) && + getApiRequestCompletionState(state).requests.includes(serviceName) && getApiState(serviceName, payload) ) { const {isError, isSuccess, isUninitialized} = getApiState(serviceName, payload); @@ -39,7 +39,7 @@ function useApiRequestCompletionState({handleSuccess, handleError, serviceName, } } }, [ - getApiRequestCompletionState(state).requestes.includes(serviceName), + getApiRequestCompletionState(state).requests.includes(serviceName), getApiState(serviceName, payload), ]); } diff --git a/webapp/src/hooks/useForm.ts b/webapp/src/hooks/useForm.ts index 64e39daf..cf9078a4 100644 --- a/webapp/src/hooks/useForm.ts +++ b/webapp/src/hooks/useForm.ts @@ -1,46 +1,46 @@ import {useState} from 'react'; // Set initial value of form fields -const getInitialFieldsValue = ( - formFields: Record, -): Record => { +const getInitialFieldValues = ( + formFields: Record, +): Record => { let fields = {}; Object.keys(formFields).forEach((field) => { fields = { ...fields, - [field as FormFields]: - formFields[field as FormFields].value || - (field as FormFields === 'timestamp' ? Date.now().toString() : ''), + [field as FormFieldNames]: + formFields[field as FormFieldNames].value || + (field as FormFieldNames === 'timestamp' ? Date.now().toString() : ''), }; }); - return fields as unknown as Record; + return fields as unknown as Record; }; /** - * Filter out all the fields for which validations check required - * and set empty string as default error message + * Filter out all the fields for which validation check is required + * and set an empty string as the default error message */ -const getFieldslWhereErrorCheckRequired = ( - formFields: Record, -): Partial> => { +const getFieldsWhereErrorCheckRequired = ( + formFields: Record, +): Partial> => { let fields = {}; Object.keys(formFields).forEach((field) => { - if (formFields[field as FormFields].validations) { + if (formFields[field as FormFieldNames].validations) { fields = { ...fields, - [field as FormFields]: '', + [field as FormFieldNames]: '', }; } }); - return fields as unknown as Partial>; + return fields as unknown as Partial>; }; -// Check each type of validations and return required error message +// Check each type of validation and return the required error message const getValidationErrorMessage = ( - formFields: Record, - fieldName: FormFields, + formFields: Record, + fieldName: FormFieldNames, fieldLabel: string, validationType: ValidationTypes, ): string => { @@ -52,40 +52,40 @@ const getValidationErrorMessage = ( } }; -// Genric hook to handle form fields -function useForm(initialFormFields: Record) { +// Generic hook to handle form fields +function useForm(initialFormFields: Record) { // Form field values - const [formFields, setFormFields] = useState(getInitialFieldsValue(initialFormFields)); + const [formFields, setFormFields] = useState(getInitialFieldValues(initialFormFields)); // Form field error state - const [errorState, setErrorState] = useState>>( - getFieldslWhereErrorCheckRequired(initialFormFields), + const [errorState, setErrorState] = useState>>( + getFieldsWhereErrorCheckRequired(initialFormFields), ); /** * Set new field value on change * and reset field error state */ - const onChangeOfFormField = (fieldName: FormFields, value: string) => { + const onChangeFormField = (fieldName: FormFieldNames, value: string) => { setErrorState({...errorState, [fieldName]: ''}); setFormFields({...formFields, [fieldName]: value}); }; // Validate all form fields and set error if any const isErrorInFormValidation = (): boolean => { - let fields = {}; + let errorFields = {}; Object.keys(initialFormFields).forEach((field) => { - if (initialFormFields[field as FormFields].validations) { - Object.keys(initialFormFields[field as FormFields].validations ?? '').forEach((validation) => { + if (initialFormFields[field as FormFieldNames].validations) { + Object.keys(initialFormFields[field as FormFieldNames].validations ?? '').forEach((validation) => { const validationMessage = getValidationErrorMessage( formFields, - field as FormFields, - initialFormFields[field as FormFields].label, + field as FormFieldNames, + initialFormFields[field as FormFieldNames].label, validation as ValidationTypes, ); if (validationMessage) { - fields = { - ...fields, + errorFields = { + ...errorFields, [field]: validationMessage, }; } @@ -93,29 +93,29 @@ function useForm(initialFormFields: Record) { } }); - if (!Object.keys(fields).length) { + if (!Object.keys(errorFields).length) { return false; } - setErrorState(fields); + setErrorState(errorFields); return true; }; // Reset form field values and error states const resetFormFields = () => { - setFormFields(getInitialFieldsValue(initialFormFields)); - setErrorState(getFieldslWhereErrorCheckRequired(initialFormFields)); + setFormFields(getInitialFieldValues(initialFormFields)); + setErrorState(getFieldsWhereErrorCheckRequired(initialFormFields)); }; // Set value for a specific form field - const setSpecificFieldValue = (fieldName: FormFields, value: string) => { + const setSpecificFieldValue = (fieldName: FormFieldNames, value: string) => { setFormFields({ ...formFields, [fieldName]: value, }); }; - return {formFields, errorState, setSpecificFieldValue, onChangeOfFormField, isErrorInFormValidation, resetFormFields}; + return {formFields, errorState, setSpecificFieldValue, onChangeFormField, isErrorInFormValidation, resetFormFields}; } export default useForm; diff --git a/webapp/src/hooks/usePluginApi.ts b/webapp/src/hooks/usePluginApi.ts index f2b7a375..54c548cc 100644 --- a/webapp/src/hooks/usePluginApi.ts +++ b/webapp/src/hooks/usePluginApi.ts @@ -11,7 +11,7 @@ function usePluginApi() { const dispatch = useDispatch(); // Pass payload only in POST rquests for GET requests there is no need to pass payload argument - const makeApiRequest = async (serviceName: ApiServiceName, payload: APIRequestPayload): Promise | any => { + const makeApiRequest = async (serviceName: ApiServiceName, payload: APIRequestPayload): Promise => { return dispatch(services.endpoints[serviceName].initiate(payload)); //TODO: add proper type here }; diff --git a/webapp/src/plugin_constants/common.ts b/webapp/src/plugin_constants/common.ts index 1e68f1cf..e0063992 100644 --- a/webapp/src/plugin_constants/common.ts +++ b/webapp/src/plugin_constants/common.ts @@ -1,4 +1,3 @@ -// Plugin configs export const pluginId = 'mattermost-plugin-azure-devops'; export const AzureDevops = 'Azure DevOps'; diff --git a/webapp/src/plugin_constants/form.ts b/webapp/src/plugin_constants/form.ts index 77b1d22e..ce22023c 100644 --- a/webapp/src/plugin_constants/form.ts +++ b/webapp/src/plugin_constants/form.ts @@ -49,7 +49,7 @@ export const subscriptionModal: Record) => { - state.requestes = [...state.requestes, action.payload]; + state.requests = [...state.requests, action.payload]; }, resetApiRequestCompletionState: (state: ApiRequestCompletionState, action: PayloadAction) => { - state.requestes = state.requestes.filter(((request) => request !== action.payload)); + state.requests = state.requests.filter(request => request !== action.payload); }, }, }); diff --git a/webapp/src/selectors/index.tsx b/webapp/src/selectors/index.tsx index 7919b26b..17a8cc9d 100644 --- a/webapp/src/selectors/index.tsx +++ b/webapp/src/selectors/index.tsx @@ -33,5 +33,5 @@ export const getApiRequestCompletionState = (state: any): ApiRequestCompletionSt }; export const getApiQueriesState = (state: any): ApiQueriesState => { - return state[pluginPrefix].azureDevopsPluginApi?.queries; + return state[pluginPrefix].azureDevOpsPluginApi?.queries; }; diff --git a/webapp/src/services/index.ts b/webapp/src/services/index.ts index 062013d8..2f9cb676 100644 --- a/webapp/src/services/index.ts +++ b/webapp/src/services/index.ts @@ -6,8 +6,8 @@ import Constants from 'plugin_constants'; import Utils from 'utils'; // Service to make plugin API requests -const azureDevopsPluginApi = createApi({ - reducerPath: 'azureDevopsPluginApi', +const azureDevOpsPluginApi = createApi({ + reducerPath: 'azureDevOpsPluginApi', baseQuery: fetchBaseQuery({baseUrl: Utils.getBaseUrls().pluginApiBaseUrl}), tagTypes: ['Posts'], endpoints: (builder) => ({ @@ -82,4 +82,4 @@ const azureDevopsPluginApi = createApi({ }), }); -export default azureDevopsPluginApi; +export default azureDevOpsPluginApi; diff --git a/webapp/src/types/common/form.d.ts b/webapp/src/types/common/form.d.ts index 71c306f3..ae52b207 100644 --- a/webapp/src/types/common/form.d.ts +++ b/webapp/src/types/common/form.d.ts @@ -11,4 +11,4 @@ type ModalFormFieldConfig = { validations?: Partial> } -type FormFields = SubscriptionModalFields +type FormFieldNames = SubscriptionModalFields diff --git a/webapp/src/types/common/store.d.ts b/webapp/src/types/common/store.d.ts index ed3ae67f..2276bbf2 100644 --- a/webapp/src/types/common/store.d.ts +++ b/webapp/src/types/common/store.d.ts @@ -1,9 +1,9 @@ type PluginState = { - 'plugins-mattermost-plugin-azure-devops': RootState<{ [x: string]: QueryDefinition, never, WellList[], 'azureDevopsPluginApi'>; }, never, 'pluginApi'> + 'plugins-mattermost-plugin-azure-devops': RootState<{ [x: string]: QueryDefinition, never, void, 'azureDevOpsPluginApi'>; }, never, 'pluginApi'> } type ApiRequestCompletionState = { - requestes: ApiServiceName[] + requests: ApiServiceName[] } type GlobalModalState = { From 387b039a086de53c524eebea3965417cf47d932c Mon Sep 17 00:00:00 2001 From: Abhishek Verma Date: Mon, 5 Sep 2022 15:30:37 +0530 Subject: [PATCH 28/28] [MI-2061]: lint fixes --- webapp/src/components/dropdown/index.tsx | 2 +- webapp/src/components/modal/index.tsx | 64 +++++++++---------- .../src/containers/SubscribeModal/index.tsx | 2 +- webapp/src/containers/TaskModal/index.tsx | 2 - webapp/src/reducers/apiRequest/index.ts | 2 +- 5 files changed, 35 insertions(+), 37 deletions(-) diff --git a/webapp/src/components/dropdown/index.tsx b/webapp/src/components/dropdown/index.tsx index ca7a8fba..7c3c5929 100644 --- a/webapp/src/components/dropdown/index.tsx +++ b/webapp/src/components/dropdown/index.tsx @@ -16,7 +16,7 @@ type DropdownProps = { error?: boolean | string; } -const Dropdown = ({value, placeholder, options, onChange, customOption, loadingOptions, disabled = false, error='', required}: DropdownProps): JSX.Element => { +const Dropdown = ({value, placeholder, options, onChange, customOption, loadingOptions, disabled = false, error = '', required}: DropdownProps): JSX.Element => { const [open, setOpen] = useState(false); // Handles closing the popover and updating the value when someone selects an option diff --git a/webapp/src/components/modal/index.tsx b/webapp/src/components/modal/index.tsx index 58591997..524c1ff3 100644 --- a/webapp/src/components/modal/index.tsx +++ b/webapp/src/components/modal/index.tsx @@ -26,39 +26,39 @@ type ModalProps = { } const Modal = ({show, onHide, showCloseIconInHeader = true, children, title, subTitle, onConfirm, confirmAction, confirmBtnText, cancelBtnText, className = '', loading = false, error, confirmDisabled = false, cancelDisabled = false}: ModalProps) => ( - + - - - - <> - - {children} - - - - - + /> + + + <> + + {children} + + + + + ); export default Modal; diff --git a/webapp/src/containers/SubscribeModal/index.tsx b/webapp/src/containers/SubscribeModal/index.tsx index d9088856..57377823 100644 --- a/webapp/src/containers/SubscribeModal/index.tsx +++ b/webapp/src/containers/SubscribeModal/index.tsx @@ -20,7 +20,7 @@ import Utils, {getOrganizationList, getProjectList} from 'utils'; import './styles.scss'; const SubscribeModal = () => { - const {subscriptionModal} = plugin_constants.form + const {subscriptionModal} = plugin_constants.form; // Hooks const { diff --git a/webapp/src/containers/TaskModal/index.tsx b/webapp/src/containers/TaskModal/index.tsx index fc9efae9..5f147aca 100644 --- a/webapp/src/containers/TaskModal/index.tsx +++ b/webapp/src/containers/TaskModal/index.tsx @@ -6,8 +6,6 @@ import Dropdown from 'components/dropdown'; import Input from 'components/inputField'; import Modal from 'components/modal'; -import Constants from 'plugin_constants'; - import {toggleShowTaskModal} from 'reducers/taskModal'; import {getCreateTaskModalState} from 'selectors'; diff --git a/webapp/src/reducers/apiRequest/index.ts b/webapp/src/reducers/apiRequest/index.ts index 7cdf39ad..7d605e09 100644 --- a/webapp/src/reducers/apiRequest/index.ts +++ b/webapp/src/reducers/apiRequest/index.ts @@ -12,7 +12,7 @@ export const apiRequestCompletionSlice = createSlice({ state.requests = [...state.requests, action.payload]; }, resetApiRequestCompletionState: (state: ApiRequestCompletionState, action: PayloadAction) => { - state.requests = state.requests.filter(request => request !== action.payload); + state.requests = state.requests.filter((request) => request !== action.payload); }, }, });