Skip to content

Commit

Permalink
[MI-2049]: Added websocket support to detect user connection details … (
Browse files Browse the repository at this point in the history
#31)

* [MI-1986]: Create plugin API to fetch linked projects list

* [MI-1987]: Integrated project list UI

* [MI-1987]: Review fixes

* [MI-2001]: [MI-2001]: Create plugin API to unlink project and integrate the UI

* [MI-2001]: Review fixes

* [MI-2002]: Created plugin API to fetch user details and UI integration with other changes

* [MI-2002]: Review fixes

* [MI-2002]: Updated API paths

* [MI-2049]: Added websocket support to detect user connection details and a centralised check for root modals

* [MI-1987]: Review fix

* [MI-1987]: Review fixes

* [MI-2001]: Review fixes

* [MI-2001]: Review fixes

* [MI-2002]: Review fixes

* [MI-2049]: Review fixes

* [MI-2049]: Fixed merge change

* [MI-2049]: Refactored code

* [MI-2049]: Review fix

* [MI-2049]: Review fix

Co-authored-by: Abhishek Verma <[email protected]>
  • Loading branch information
avas27JTG and avas27JTG authored Sep 1, 2022
1 parent fc74ee8 commit c87e47b
Show file tree
Hide file tree
Showing 26 changed files with 248 additions and 142 deletions.
4 changes: 4 additions & 0 deletions server/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,8 @@ const (
PageQueryParam = "$top"
APIVersionQueryParam = "api-version"
IDsQueryParam = "ids"

// Websocket events
WSEventConnect = "connect"
WSEventDisconnect = "disconnect"
)
15 changes: 15 additions & 0 deletions server/plugin/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ var azureDevopsCommandHandler = Handler{
"help": azureDevopsHelpCommand,
"connect": azureDevopsConnectCommand,
"disconnect": azureDevopsDisconnectCommand,
"link": azureDevopsAccountConnectionCheck,
},
defaultHandler: executeDefault,
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
6 changes: 6 additions & 0 deletions server/plugin/oAuth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 4 additions & 0 deletions server/serializers/oAuth.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ type OAuthSuccessResponse struct {
RefreshToken string `json:"refresh_token"`
ExpiresIn string `json:"expires_in"`
}

type ConnectedResponse struct {
IsConnected bool `json:"connected"`
}
6 changes: 3 additions & 3 deletions server/store/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}

Expand Down
44 changes: 39 additions & 5 deletions webapp/src/app.tsx
Original file line number Diff line number Diff line change
@@ -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 => <Rhs/>;
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;
10 changes: 0 additions & 10 deletions webapp/src/components/emptyState/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,6 @@
&__btn {
margin-top: 24px;
}

svg {
fill: rgba(var(--center-channel-color-rgb), 0.5);
}
}

.slash-command {
padding: 10px 15px;
border-radius: 4px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
}

.slash-command {
Expand Down
10 changes: 6 additions & 4 deletions webapp/src/containers/LinkModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -35,7 +35,7 @@ const LinkModal = () => {
organization: '',
project: '',
});
dispatch(hideLinkModal());
dispatch(toggleShowLinkModal({isVisible: false, commandArgs: []}));
};

// Set organization name
Expand Down Expand Up @@ -82,14 +82,16 @@ const LinkModal = () => {
}
};

// Set modal field values
useEffect(() => {
setProjectDetails({
organization: getLinkModalState(usePlugin.state).organization,
project: getLinkModalState(usePlugin.state).project,
});
}, [getLinkModalState(usePlugin.state)]);
}, [getLinkModalState(usePlugin.state).visibility]);

const {isLoading} = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.createLink.apiServiceName, projectDetails);

return (
<Modal
show={getLinkModalState(usePlugin.state).visibility}
Expand All @@ -105,7 +107,7 @@ const LinkModal = () => {
<Input
type='text'
placeholder='Organization name'
value={projectDetails.project}
value={projectDetails.organization}
onChange={onOrganizationChange}
error={errorState.organization}
required={true}
Expand Down
12 changes: 0 additions & 12 deletions webapp/src/containers/Rhs/accountNotLinked/index.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,13 @@
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 (
Expand Down
35 changes: 8 additions & 27 deletions webapp/src/containers/Rhs/index.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,25 @@
import React, {useEffect} from 'react';
import React from 'react';

import usePluginApi from 'hooks/usePluginApi';
import {getprojectDetailsState, getRhsState} from 'selectors';

import LinearLoader from 'components/loader/linear';

import plugin_constants from 'plugin_constants';
import {getprojectDetailsState} from 'selectors';

import AccountNotLinked from './accountNotLinked';
import ProjectList from './projectList';
import ProjectDetails from './projectDetails';

const Rhs = (): JSX.Element => {
const {getApiState, makeApiRequest, state} = usePluginApi();

// Fetch the connected account details when RHS is opened
useEffect(() => {
if (getRhsState(state).isSidebarOpen) {
makeApiRequest(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName);
}
}, []);

const {isLoading, isError, isSuccess} = getApiState(plugin_constants.pluginApiServiceConfigs.getUserDetails.apiServiceName);

if (!getRhsState(state).isSidebarOpen) {
return <></>;
}
const {isUserAccountConnected, state} = usePluginApi();

return (
<div className='overflow-auto height-rhs bg-sidebar padding-25'>
{isLoading && <LinearLoader/>}
{isError && <AccountNotLinked/>}
{
!isLoading &&
!isError &&
isSuccess && (
!isUserAccountConnected() && <AccountNotLinked/>
}
{
isUserAccountConnected() && (
getprojectDetailsState(state).projectID ?
<ProjectDetails title={getprojectDetailsState(state).projectName}/> :
<ProjectList/>
)
<ProjectList/>)
}
</div>
);
Expand Down
6 changes: 3 additions & 3 deletions webapp/src/containers/Rhs/projectList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -31,7 +31,7 @@ const ProjectList = () => {

// Opens link project modal
const handleOpenLinkProjectModal = () => {
dispatch(showLinkModal([]));
dispatch(toggleShowLinkModal({isVisible: true, commandArgs: []}));
};

/**
Expand Down Expand Up @@ -64,7 +64,7 @@ const ProjectList = () => {
dispatch(toggleIsLinked(false));
fetchLinkedProjectsList();
}
}, [getLinkModalState(usePlugin.state)]);
}, [getLinkModalState(usePlugin.state).isLinked]);

const {data, isSuccess, isLoading} = usePlugin.getApiState(plugin_constants.pluginApiServiceConfigs.getAllLinkedProjectsList.apiServiceName);

Expand Down
3 changes: 2 additions & 1 deletion webapp/src/containers/TaskModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Modal from 'components/modal';
import Constants from 'plugin_constants';
import usePluginApi from 'hooks/usePluginApi';
import {hideTaskModal} from 'reducers/taskModal';
import {getTaskModalState} from 'selectors';

// TODO: fetch the organization and project options from API later.
const organizationOptions = [
Expand Down Expand Up @@ -69,7 +70,7 @@ const TaskModal = () => {
const [taskTitleError, setTaskTitleError] = useState('');
const [taskPayload, setTaskPayload] = useState<CreateTaskPayload | null>();
const usePlugin = usePluginApi();
const {visibility} = usePlugin.state['plugins-mattermost-plugin-azure-devops'].openTaskModalReducer;
const {visibility} = getTaskModalState(usePlugin.state);
const dispatch = useDispatch();

useEffect(() => {
Expand Down
32 changes: 7 additions & 25 deletions webapp/src/hooks/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {showLinkModal} from 'reducers/linkModal';
import {showTaskModal} from 'reducers/taskModal';
import {splitArgs} from '../utils';
import {setGlobalModalState} from 'reducers/globalModal';
import {getCommandArgs} from 'utils';

export default class Hooks {
constructor(store) {
Expand All @@ -20,36 +19,19 @@ export default class Hooks {
commandTrimmed = message.trim();
}

if (!commandTrimmed?.startsWith('/azuredevops')) {
if (commandTrimmed && commandTrimmed.startsWith('/azuredevops link')) {
const commandArgs = getCommandArgs(commandTrimmed);
this.store.dispatch(setGlobalModalState({modalId: 'linkProject', commandArgs}));
return Promise.resolve({
message,
args: contextArgs,
});
}
if (commandTrimmed && commandTrimmed.startsWith('/azuredevops boards create')) {
const args = splitArgs(commandTrimmed);
this.store.dispatch(showTaskModal(args));
// TODO: refactor
// const args = splitArgs(commandTrimmed);
return Promise.resolve({});
}
if (commandTrimmed && commandTrimmed.startsWith('/azuredevops link')) {
const args = splitArgs(commandTrimmed);
this.store.dispatch(showLinkModal(args));
return Promise.resolve({});
}
if (commandTrimmed && commandTrimmed.startsWith('/azuredevops connect')) {
this.closeRhs();
return {
message,
args: contextArgs,
};
}
if (commandTrimmed && commandTrimmed.startsWith('/azuredevops disconnect')) {
this.closeRhs();
return {
message,
args: contextArgs,
};
}
return Promise.resolve({
message,
args: contextArgs,
Expand Down
6 changes: 5 additions & 1 deletion webapp/src/hooks/usePluginApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading

0 comments on commit c87e47b

Please sign in to comment.