From 4d5aad8c49d0b239510fc6c51f24347cfecfd3ba Mon Sep 17 00:00:00 2001 From: SaurabhSharma-884 Date: Fri, 15 Dec 2023 18:28:46 +0530 Subject: [PATCH] [MI-3833]: Link channel modal integration (#43) * [MI-3833]: Link Channel Modal integration * [MI-3833]: Add constants * [MI-3833]: Update constants * [MI-3837]: Integrate webhooks and enhancements (#47) * [MI-3837]: Integrate webhooks and enhancements * [MI-3837]: Remove yarn lock * [MI-3834] Add unlink button on hover state on linked channel list (#45) * [MI-3834] Add unlink button on hover state on linked channel list * [MI-3834] Update style and props of unlink button * [MI-3836] Integrate API to unlink channels and websocket event (#46) * [MI-3836] Integrate API to unlink channels and websocket event * [MI-3836] Fix issue of alert not visible * [MI-3836] Update dialog component * [MI-3836] Minor review fixes and revert some changes --------- Co-authored-by: Ayush Thakur <100013900+ayusht2810@users.noreply.github.com> --- server/api.go | 1 - .../components/Dialog/Dialog.component.tsx | 43 ++--- webapp/src/components/Icon/Icon.map.tsx | 1 - .../LinkChannelModal.component.tsx | 147 ++++++++++++---- .../SearchMMChannels.component.tsx | 28 +++- .../SearchMSChannels.component.tsx | 23 ++- .../SearchMSTeams/SearchMSTeams.component.tsx | 17 +- .../LinkedChannelCard.component.tsx | 157 +++++++++++++----- .../LinkedChannelCard.styles.scss | 28 +++- .../LinkedChannelCard.types.ts | 2 +- webapp/src/components/index.ts | 2 +- webapp/src/constants/apiService.constant.ts | 10 ++ webapp/src/constants/common.constants.ts | 4 +- webapp/src/constants/index.ts | 4 +- webapp/src/containers/Rhs/Rhs.container.tsx | 98 ++++++----- webapp/src/containers/Rhs/Rhs.styles.scss | 10 ++ webapp/src/hooks/useDialog.tsx | 32 ---- webapp/src/index.tsx | 11 +- webapp/src/reducers/dialog/index.ts | 37 ----- webapp/src/reducers/index.ts | 4 +- webapp/src/reducers/linkModal/index.ts | 19 ++- webapp/src/reducers/refetchState/index.ts | 24 +++ webapp/src/selectors/index.tsx | 10 +- webapp/src/services/index.ts | 15 ++ webapp/src/styles/_variables.scss | 2 +- webapp/src/types/common/payload.d.ts | 7 + webapp/src/types/common/service.d.ts | 4 +- webapp/src/types/common/store.d.ts | 14 +- webapp/src/websocket/index.ts | 20 +++ 29 files changed, 506 insertions(+), 268 deletions(-) delete mode 100644 webapp/src/hooks/useDialog.tsx delete mode 100644 webapp/src/reducers/dialog/index.ts create mode 100644 webapp/src/reducers/refetchState/index.ts diff --git a/server/api.go b/server/api.go index 1876bd1b9..7bb6593bf 100644 --- a/server/api.go +++ b/server/api.go @@ -91,7 +91,6 @@ func NewAPI(p *Plugin, store store.Store) *API { router.HandleFunc("/connect", api.handleAuthRequired(api.connect)).Methods(http.MethodGet) router.HandleFunc("/disconnect", api.handleAuthRequired(api.checkUserConnected(api.disconnect))).Methods(http.MethodGet) router.HandleFunc("/linked-channels", api.handleAuthRequired(api.getLinkedChannels)).Methods(http.MethodGet) - router.HandleFunc("/link-channels", api.handleAuthRequired(api.checkUserConnected(api.linkChannels))).Methods(http.MethodPost) router.HandleFunc("/oauth-redirect", api.oauthRedirectHandler).Methods(http.MethodGet) router.HandleFunc("/connected-users", api.getConnectedUsers).Methods(http.MethodGet) router.HandleFunc("/connected-users/download", api.getConnectedUsersFile).Methods(http.MethodGet) diff --git a/webapp/src/components/Dialog/Dialog.component.tsx b/webapp/src/components/Dialog/Dialog.component.tsx index f20b935f0..b1a1e4141 100644 --- a/webapp/src/components/Dialog/Dialog.component.tsx +++ b/webapp/src/components/Dialog/Dialog.component.tsx @@ -1,35 +1,16 @@ import React from 'react'; -import {useDispatch} from 'react-redux'; import {Dialog as MMDialog, LinearProgress, DialogProps} from '@brightscout/mattermost-ui-library'; -import usePluginApi from 'hooks/usePluginApi'; -import {getDialogState} from 'selectors'; -import {closeDialog} from 'reducers/dialog'; - -export const Dialog = ({onCloseHandler, onSubmitHandler}: Pick) => { - const dispatch = useDispatch(); - const {state} = usePluginApi(); - const {show, title, description, destructive, primaryButtonText, secondaryButtonText, isLoading} = getDialogState(state); - - const handleClose = () => dispatch(closeDialog()); - - return ( - { - handleClose(); - onCloseHandler(); - }} - onSubmitHandler={onSubmitHandler} - className='disconnect-dialog' - title={title} - > - {isLoading && } - - ); -}; +export const Dialog = ({ + children, + isLoading = false, + ...rest +}: DialogProps & {isLoading?: boolean, children: React.ReactNode}) => ( + + {isLoading && } + {children} + +); diff --git a/webapp/src/components/Icon/Icon.map.tsx b/webapp/src/components/Icon/Icon.map.tsx index 72b76be67..a59a8bee2 100644 --- a/webapp/src/components/Icon/Icon.map.tsx +++ b/webapp/src/components/Icon/Icon.map.tsx @@ -292,7 +292,6 @@ export const IconMap : Record = { width='18' height='18' transform='translate(0.5 0.5)' - fill='white' /> void}) => { - const {state} = usePluginApi(); +export const LinkChannelModal = () => { + const dispatch = useDispatch(); + const showAlert = useAlert(); + const {state, makeApiRequestWithCompletionStatus} = usePluginApi(); const {show = false, isLoading} = getLinkModalState(state); - const currentTeam = getCurrentTeam(state); + const {currentTeamId} = getCurrentTeam(state); + + // Show retry dialog component + const [showRetryDialog, setShowRetryDialog] = useState(false); + const [mmChannel, setMMChannel] = useState(null); const [msTeam, setMSTeam] = useState(null); const [msChannel, setMSChannel] = useState(null); + const [linkChannelsPayload, setLinkChannelsPayload] = useState(null); - const handleModalClose = () => { - setMMChannel(null); - setMSTeam(null); - setMSChannel(null); - onClose(); + const handleModalClose = (preserve?: boolean) => { + if (!preserve) { + setMMChannel(null); + setMSTeam(null); + setMSChannel(null); + } + dispatch(resetState()); + dispatch(hideLinkModal()); }; + const handleChannelLinking = () => { + const payload: LinkChannelsPayload = { + mattermostTeamID: currentTeamId || '', + mattermostChannelID: mmChannel?.id || '', + msTeamsTeamID: msTeam?.ID || '', + msTeamsChannelID: msChannel?.ID || '', + }; + setLinkChannelsPayload(payload); + makeApiRequestWithCompletionStatus(pluginApiServiceConfigs.linkChannels.apiServiceName, payload); + dispatch(setLinkModalLoading(true)); + }; + + useApiRequestCompletionState({ + serviceName: pluginApiServiceConfigs.linkChannels.apiServiceName, + payload: linkChannelsPayload as LinkChannelsPayload, + handleSuccess: () => { + dispatch(setLinkModalLoading(false)); + handleModalClose(); + dispatch(refetch()); + showAlert({ + message: 'Successfully linked channels', + severity: 'success', + }); + }, + handleError: () => { + dispatch(setLinkModalLoading(false)); + handleModalClose(true); + setShowRetryDialog(true); + }, + }); + return ( - { - // TODO: handle channel linking - }} - > - {isLoading && } - -
- - -
+ <> + + {isLoading && } + +
+ + +
+ { + dispatch(preserveState({ + mmChannel: mmChannel?.displayName ?? '', + msChannel: msChannel?.DisplayName ?? '', + msTeam: msTeam?.DisplayName ?? '', + })); + setShowRetryDialog(false); + dispatch(showLinkModal()); + }} + onCloseHandler={() => { + setShowRetryDialog(false); + dispatch(resetState()); + }} + > + {'We were not able to link the selected channels. Please try again.'} + + ); }; diff --git a/webapp/src/components/LinkChannelModal/SearchMMChannels/SearchMMChannels.component.tsx b/webapp/src/components/LinkChannelModal/SearchMMChannels/SearchMMChannels.component.tsx index 2bb9de9c2..6646f4541 100644 --- a/webapp/src/components/LinkChannelModal/SearchMMChannels/SearchMMChannels.component.tsx +++ b/webapp/src/components/LinkChannelModal/SearchMMChannels/SearchMMChannels.component.tsx @@ -1,6 +1,8 @@ import React, {useCallback, useEffect, useState} from 'react'; import {Client4} from 'mattermost-redux/client'; +import {channels as MMChannelTypes} from 'mattermost-redux/types'; +import {General as MMConstants} from 'mattermost-redux/constants'; import {ListItemType, MMSearch} from '@brightscout/mattermost-ui-library'; @@ -10,10 +12,13 @@ import utils from 'utils'; import {Icon} from 'components/Icon'; -import {debounceFunctionTimeLimit} from 'constants/common.constants'; +import {debounceFunctionTimeLimitInMilliseconds} from 'constants/common.constants'; import {setLinkModalLoading} from 'reducers/linkModal'; +import usePluginApi from 'hooks/usePluginApi'; +import {getCurrentTeam, getLinkModalState} from 'selectors'; + import {SearchMMChannelProps} from './SearchMMChannels.types'; export const SearchMMChannels = ({ @@ -21,26 +26,34 @@ export const SearchMMChannels = ({ teamId, }: SearchMMChannelProps) => { const dispatch = useDispatch(); + const {state} = usePluginApi(); const [searchTerm, setSearchTerm] = useState(''); + const {teams} = getCurrentTeam(state); + const {mmChannel} = getLinkModalState(state); const [searchSuggestions, setSearchSuggestions] = useState([]); const [suggestionsLoading, setSuggestionsLoading] = useState(false); useEffect(() => { - handleClearInput(); + if (!mmChannel) { + handleClearInput(); + } + setSearchTerm(mmChannel); }, [teamId]); const searchChannels = ({searchFor}: {searchFor?: string}) => { - if (searchFor && teamId) { + if (searchFor) { setSuggestionsLoading(true); dispatch(setLinkModalLoading(true)); - Client4.autocompleteChannelsForSearch(teamId, searchFor). + Client4.searchAllChannels(searchFor). then((channels) => { - const suggestions = []; - for (const channel of channels) { + const suggestions:ListItemType[] = []; + for (const channel of channels as MMChannelTypes.Channel[]) { suggestions.push({ label: channel.display_name, value: channel.id, + secondaryLabel: teams[channel.team_id].display_name, + icon: channel.type === MMConstants.PRIVATE_CHANNEL ? 'Lock' : 'Globe', }); } setSearchSuggestions(suggestions); @@ -53,7 +66,7 @@ export const SearchMMChannels = ({ } }; - const debouncedSearchChannels = useCallback(utils.debounce(searchChannels, debounceFunctionTimeLimit), [searchChannels]); + const debouncedSearchChannels = useCallback(utils.debounce(searchChannels, debounceFunctionTimeLimitInMilliseconds), [searchChannels]); const handleSearch = (val: string) => { if (!val) { @@ -89,6 +102,7 @@ export const SearchMMChannels = ({ fullWidth={true} label='Search Mattermost channels' items={searchSuggestions} + secondaryLabelPosition='inline' onSelect={handleChannelSelect} searchValue={searchTerm} setSearchValue={handleSearch} diff --git a/webapp/src/components/LinkChannelModal/SearchMSChannels/SearchMSChannels.component.tsx b/webapp/src/components/LinkChannelModal/SearchMSChannels/SearchMSChannels.component.tsx index 0fe192d11..9c84acd1c 100644 --- a/webapp/src/components/LinkChannelModal/SearchMSChannels/SearchMSChannels.component.tsx +++ b/webapp/src/components/LinkChannelModal/SearchMSChannels/SearchMSChannels.component.tsx @@ -6,23 +6,31 @@ import {useDispatch} from 'react-redux'; import usePluginApi from 'hooks/usePluginApi'; import utils from 'utils'; -import {debounceFunctionTimeLimit, defaultPage, defaultPerPage} from 'constants/common.constants'; +import {debounceFunctionTimeLimitInMilliseconds, defaultPage, defaultPerPage} from 'constants/common.constants'; import {pluginApiServiceConfigs} from 'constants/apiService.constant'; import useApiRequestCompletionState from 'hooks/useApiRequestCompletionState'; import {setLinkModalLoading} from 'reducers/linkModal'; +import {Icon} from 'components/Icon'; + +import {getLinkModalState} from 'selectors'; + import {SearchMSChannelProps} from './SearchMSChannels.types'; export const SearchMSChannels = ({setChannel, teamId}: SearchMSChannelProps) => { const dispatch = useDispatch(); - const {makeApiRequestWithCompletionStatus, getApiState} = usePluginApi(); + const {makeApiRequestWithCompletionStatus, getApiState, state} = usePluginApi(); + const {msChannel} = getLinkModalState(state); const [searchTerm, setSearchTerm] = useState(''); const [searchChannelsPayload, setSearchChannelsPayload] = useState(null); const [searchSuggestions, setSearchSuggestions] = useState([]); useEffect(() => { - handleClearInput(); + if (!msChannel || !teamId) { + handleClearInput(); + } + setSearchTerm(msChannel); }, [teamId]); const searchChannels = ({searchFor}: {searchFor?: string}) => { @@ -39,7 +47,7 @@ export const SearchMSChannels = ({setChannel, teamId}: SearchMSChannelProps) => } }; - const debouncedSearchChannels = useCallback(utils.debounce(searchChannels, debounceFunctionTimeLimit), [searchChannels]); + const debouncedSearchChannels = useCallback(utils.debounce(searchChannels, debounceFunctionTimeLimitInMilliseconds), [searchChannels]); const handleSearch = (val: string) => { if (!val) { @@ -75,6 +83,10 @@ export const SearchMSChannels = ({setChannel, teamId}: SearchMSChannelProps) => suggestions.push({ label: channel.DisplayName, value: channel.ID, + secondaryLabel: channel.ID, + + // TODO: Replace with msteams icon + icon: 'Pin', }); } setSearchSuggestions(suggestions); @@ -83,8 +95,6 @@ export const SearchMSChannels = ({setChannel, teamId}: SearchMSChannelProps) => }, handleError: () => { dispatch(setLinkModalLoading(false)); - - // TODO: Handle this error }, }); @@ -98,6 +108,7 @@ export const SearchMSChannels = ({setChannel, teamId}: SearchMSChannelProps) => searchValue={searchTerm} setSearchValue={handleSearch} onClearInput={handleClearInput} + secondaryLabelPosition='inline' optionsLoading={searchSuggestionsLoading} disabled={!teamId} /> diff --git a/webapp/src/components/LinkChannelModal/SearchMSTeams/SearchMSTeams.component.tsx b/webapp/src/components/LinkChannelModal/SearchMSTeams/SearchMSTeams.component.tsx index b8e8936f8..ec40345a9 100644 --- a/webapp/src/components/LinkChannelModal/SearchMSTeams/SearchMSTeams.component.tsx +++ b/webapp/src/components/LinkChannelModal/SearchMSTeams/SearchMSTeams.component.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useState} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {ListItemType, MMSearch} from '@brightscout/mattermost-ui-library'; @@ -6,19 +6,25 @@ import {useDispatch} from 'react-redux'; import {Icon} from 'components/Icon'; import {pluginApiServiceConfigs} from 'constants/apiService.constant'; -import {debounceFunctionTimeLimit, defaultPage, defaultPerPage} from 'constants/common.constants'; +import {debounceFunctionTimeLimitInMilliseconds, defaultPage, defaultPerPage} from 'constants/common.constants'; import useApiRequestCompletionState from 'hooks/useApiRequestCompletionState'; import usePluginApi from 'hooks/usePluginApi'; import utils from 'utils'; import {setLinkModalLoading} from 'reducers/linkModal'; +import {getLinkModalState} from 'selectors'; export const SearchMSTeams = ({setMSTeam}: {setMSTeam: React.Dispatch>}) => { const dispatch = useDispatch(); - const {makeApiRequestWithCompletionStatus, getApiState} = usePluginApi(); + const {makeApiRequestWithCompletionStatus, getApiState, state} = usePluginApi(); const [searchTerm, setSearchTerm] = useState(''); + const {msTeam} = getLinkModalState(state); const [searchTeamsPayload, setSearchTeamsPayload] = useState(null); const [searchSuggestions, setSearchSuggestions] = useState([]); + useEffect(() => { + setSearchTerm(msTeam); + }, []); + const searchTeams = ({searchFor}: {searchFor?: string}) => { if (searchFor) { const payload = { @@ -32,7 +38,7 @@ export const SearchMSTeams = ({setMSTeam}: {setMSTeam: React.Dispatch { if (!val) { @@ -68,6 +74,9 @@ export const SearchMSTeams = ({setMSTeam}: {setMSTeam: React.Dispatch ( -
-
- -
-
-
- {mattermostChannelType === MMConstants.PRIVATE_CHANNEL ? : } - -
{mattermostChannelName}
-
- -
{mattermostTeamName}
-
+export const LinkedChannelCard = ({msTeamsChannelName, msTeamsTeamName, mattermostChannelName, mattermostTeamName, mattermostChannelType, mattermostChannelID}: LinkedChannelCardProps) => { + const [unlinkChannelParams, setUnlinkChannelParams] = useState(null); + + // Show unlink and retry dialog component + const [showUnlinkDialog, setShowUnlinkDialog] = useState(false); + const [showRetryDialog, setShowRetryDialog] = useState(false); + + const {makeApiRequestWithCompletionStatus, getApiState} = usePluginApi(); + const dispatch = useDispatch(); + const showAlert = useAlert(); + + const {isLoading: isUnlinkChannelsLoading} = getApiState(pluginApiServiceConfigs.unlinkChannel.apiServiceName, unlinkChannelParams as UnlinkChannelParams); + + const unlinkChannel = () => { + if (unlinkChannelParams?.channelId) { + makeApiRequestWithCompletionStatus(pluginApiServiceConfigs.unlinkChannel.apiServiceName, {channelId: unlinkChannelParams.channelId}); + } + }; + + useApiRequestCompletionState({ + serviceName: pluginApiServiceConfigs.unlinkChannel.apiServiceName, + payload: unlinkChannelParams as UnlinkChannelParams, + handleSuccess: () => { + setShowUnlinkDialog(false); + dispatch(refetch()); + showAlert({message: 'Successfully unlinked channels.', severity: 'success'}); + }, + handleError: () => { + setShowUnlinkDialog(false); + setShowRetryDialog(true); + }, + }); + + return ( +
+
+
-
- - -
{msTeamsChannelName}
-
- -
{msTeamsTeamName}
-
+
+
+ {mattermostChannelType === MMConstants.PRIVATE_CHANNEL ? : } + +
{mattermostChannelName}
+
+ +
{mattermostTeamName}
+
+
+
+ + +
{msTeamsChannelName}
+
+ +
{msTeamsTeamName}
+
+
+ + setShowUnlinkDialog(false)} + isLoading={isUnlinkChannelsLoading} + > + <>{'Are you sure you want to unlink the '}{mattermostChannelName}{' and '} {msTeamsChannelName}{' channels? Messages will no longer be synced.'} + + setShowRetryDialog(false)} + isLoading={isUnlinkChannelsLoading} + > + {'We were not able to unlink the selected channels. Please try again.'} +
-
-); + ); +}; diff --git a/webapp/src/components/LinkedChannelCard/LinkedChannelCard.styles.scss b/webapp/src/components/LinkedChannelCard/LinkedChannelCard.styles.scss index 643af210c..ed36b6e2b 100644 --- a/webapp/src/components/LinkedChannelCard/LinkedChannelCard.styles.scss +++ b/webapp/src/components/LinkedChannelCard/LinkedChannelCard.styles.scss @@ -2,6 +2,30 @@ .msteams-linked-channel { + &__unlink-icon { + width: 15%; + display: none; + + svg { + path,rect{ + color: var(--center-channel-color-56); + } + } + } + + &:hover { + background-color: var(--center-channel-color-08); + + .msteams-linked-channel__unlink-icon { + display: flex; + cursor: pointer; + + .mm-icon { + margin: 0 !important; + } + } + } + &__link-icon { &::before { @@ -28,14 +52,14 @@ } &__body { - width: 95%; + width: 80%; } &__body-values { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: 45%; + max-width: 35%; line-height: inherit; } } diff --git a/webapp/src/components/LinkedChannelCard/LinkedChannelCard.types.ts b/webapp/src/components/LinkedChannelCard/LinkedChannelCard.types.ts index 7366df2a7..353bf168c 100644 --- a/webapp/src/components/LinkedChannelCard/LinkedChannelCard.types.ts +++ b/webapp/src/components/LinkedChannelCard/LinkedChannelCard.types.ts @@ -1,3 +1,3 @@ -export type LinkedChannelCardProps = Pick & { +export type LinkedChannelCardProps = Pick & { channelId: string } diff --git a/webapp/src/components/index.ts b/webapp/src/components/index.ts index e63f61d09..0b80d0ad8 100644 --- a/webapp/src/components/index.ts +++ b/webapp/src/components/index.ts @@ -3,5 +3,5 @@ export * from './Icon'; export * from './WarningCard'; export * from './LinkedChannelCard'; export * from './Snackbar'; -export * from './Dialog'; export * from './LinkChannelModal'; +export * from './Dialog'; diff --git a/webapp/src/constants/apiService.constant.ts b/webapp/src/constants/apiService.constant.ts index 58667b627..8bde1b16c 100644 --- a/webapp/src/constants/apiService.constant.ts +++ b/webapp/src/constants/apiService.constant.ts @@ -35,4 +35,14 @@ export const pluginApiServiceConfigs: Record = { success: 'success', @@ -18,3 +16,5 @@ export const alertSeverity: Record = { } as const; export const alertTimeout = 4000; + +export const debounceFunctionTimeLimitInMilliseconds = 300; diff --git a/webapp/src/constants/index.ts b/webapp/src/constants/index.ts index ccca4c551..371c1889c 100644 --- a/webapp/src/constants/index.ts +++ b/webapp/src/constants/index.ts @@ -1,7 +1,7 @@ import {pluginApiServiceConfigs} from './apiService.constant'; import {channelListTitle, noMoreChannelsText} from './linkedChannels.constants'; import {iconUrl} from './illustrations.constants'; -import {pluginTitle, siteUrl, alertTimeout, debounceFunctionTimeLimit} from './common.constants'; +import {pluginTitle, siteUrl, alertTimeout, debounceFunctionTimeLimitInMilliseconds} from './common.constants'; export default { iconUrl, @@ -11,5 +11,5 @@ export default { channelListTitle, alertTimeout, noMoreChannelsText, - debounceFunctionTimeLimit, + debounceFunctionTimeLimitInMilliseconds, }; diff --git a/webapp/src/containers/Rhs/Rhs.container.tsx b/webapp/src/containers/Rhs/Rhs.container.tsx index b3c55c3e3..d09422af2 100644 --- a/webapp/src/containers/Rhs/Rhs.container.tsx +++ b/webapp/src/containers/Rhs/Rhs.container.tsx @@ -4,24 +4,26 @@ import {useDispatch} from 'react-redux'; import {Button, Input, Spinner} from '@brightscout/mattermost-ui-library'; -import {Icon, IconName, LinkedChannelCard, Snackbar, WarningCard} from 'components'; +import {Dialog, Icon, IconName, LinkChannelModal, LinkedChannelCard, Snackbar, WarningCard} from 'components'; import {pluginApiServiceConfigs} from 'constants/apiService.constant'; -import {debounceFunctionTimeLimit, defaultPage, defaultPerPage} from 'constants/common.constants'; +import {debounceFunctionTimeLimitInMilliseconds, defaultPage, defaultPerPage} from 'constants/common.constants'; import Constants from 'constants/connectAccount.constants'; import {channelListTitle, noMoreChannelsText} from 'constants/linkedChannels.constants'; import useApiRequestCompletionState from 'hooks/useApiRequestCompletionState'; import useAlert from 'hooks/useAlert'; -import useDialog from 'hooks/useDialog'; import usePluginApi from 'hooks/usePluginApi'; import usePreviousState from 'hooks/usePreviousState'; -import {getConnectedState, getIsRhsLoading, getSnackbarState} from 'selectors'; +import {getConnectedState, getIsRhsLoading, getLinkModalState, getRefetchState, getSnackbarState} from 'selectors'; import {setConnected} from 'reducers/connectedState'; +import {showLinkModal} from 'reducers/linkModal'; +import {resetRefetch} from 'reducers/refetchState'; import utils from 'utils'; import './Rhs.styles.scss'; export const Rhs = () => { const {makeApiRequestWithCompletionStatus, getApiState, state} = usePluginApi(); + const {Avatar} = window.Components; // state variables const [totalLinkedChannels, setTotalLinkedChannels] = useState([]); @@ -67,18 +69,16 @@ export const Rhs = () => { ), [totalLinkedChannels]); // Show disconnect dialog component - const {DialogComponent, showDialog, hideDialog} = useDialog({ - onSubmitHandler: disconnectUser, - onCloseHandler: () => { - hideDialog(); - }, - }); + const [showDisconnectDialog, setShowDisconnectDialog] = useState(false); const {isRhsLoading} = getIsRhsLoading(state); const {isOpen} = getSnackbarState(state); + const {refetch} = getRefetchState(state); const {data} = getApiState(pluginApiServiceConfigs.whitelistUser.apiServiceName); + const {presentInWhitelist} = data as WhitelistUserResponse; const {data: linkedChannels, isLoading} = getApiState(pluginApiServiceConfigs.getLinkedChannels.apiServiceName, getLinkedChannelsParams as SearchParams); + const {isLoading: isUserDisconnecting} = getApiState(pluginApiServiceConfigs.disconnectUser.apiServiceName); // Handle searching of linked channels with debounce useEffect(() => { @@ -88,7 +88,7 @@ export const Rhs = () => { const timer = setTimeout(() => { resetStates(); - }, debounceFunctionTimeLimit); + }, debounceFunctionTimeLimitInMilliseconds); /* eslint-disable consistent-return */ return () => { @@ -134,7 +134,7 @@ export const Rhs = () => { serviceName: pluginApiServiceConfigs.disconnectUser.apiServiceName, handleSuccess: () => { dispatch(setConnected({connected: false, username: '', msteamsUserId: '', isAlreadyConnected: false})); - hideDialog(); + setShowDisconnectDialog(false); showAlert({ message: 'Your account has been disconnected.', severity: 'default', @@ -145,10 +145,17 @@ export const Rhs = () => { message: 'Error occurred while disconnecting the user.', severity: 'error', }); - hideDialog(); + setShowDisconnectDialog(false); }, }); + useEffect(() => { + if (refetch) { + resetStates(); + dispatch(resetRefetch()); + } + }, [refetch]); + // Get different states of rhs const getRhsView = useCallback(() => { // Show spinner in the rhs during loading @@ -203,40 +210,16 @@ export const Rhs = () => {
{connected ? (
- {/* TODO: Refactor user Avatar */} -
- -
- +
{'Connected as '}{username}
-
) : (
@@ -255,16 +238,28 @@ export const Rhs = () => { {/* State when user is connected, but no linked channels are present. */} {!totalLinkedChannels.length && !isLoading && !searchLinkedChannelsText && !previousState?.searchLinkedChannelsText && (
- {<> - -

{'There are no linked channels yet'}

- } + +

{'There are no linked channels yet'}

+
)} {/* State when user is conected and linked channels are present. */} {((Boolean(totalLinkedChannels.length) || isLoading || searchLinkedChannelsText || previousState?.searchLinkedChannelsText) && !firstRender) && ( <> -

{channelListTitle}

+
+

{channelListTitle}

+ {/* TODO: Replace with Add icon after ui lib version bump */} + {connected && ( + + )} +
{ )} )} + setShowDisconnectDialog(false)} + > + {'Are you sure you want to disconnect your Microsoft Teams Account? You will no longer be able to send and receive messages to Microsoft Teams users from Mattermost.'} +
); - }, [connected, isRhsLoading, isLoading, totalLinkedChannels, firstRender, searchLinkedChannelsText]); + }, [connected, isRhsLoading, isLoading, totalLinkedChannels, firstRender, searchLinkedChannelsText, showDisconnectDialog]); return ( <> @@ -322,6 +329,7 @@ export const Rhs = () => { getRhsView() : 'MS Teams Sync plugin' } {isOpen && } + {} ); }; diff --git a/webapp/src/containers/Rhs/Rhs.styles.scss b/webapp/src/containers/Rhs/Rhs.styles.scss index f88f717eb..2034a7364 100644 --- a/webapp/src/containers/Rhs/Rhs.styles.scss +++ b/webapp/src/containers/Rhs/Rhs.styles.scss @@ -15,3 +15,13 @@ } } } + +.msteams-sync-modal { + .mm-autocomplete { + .mm-menuItem { + &__secondary-label { + text-align: end; + } + } + } +} diff --git a/webapp/src/hooks/useDialog.tsx b/webapp/src/hooks/useDialog.tsx deleted file mode 100644 index 03a37f259..000000000 --- a/webapp/src/hooks/useDialog.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, {useCallback} from 'react'; -import {useDispatch} from 'react-redux'; - -import {DialogProps} from '@brightscout/mattermost-ui-library'; - -import {showDialog as showDialogComponent, closeDialog} from 'reducers/dialog'; - -import {Dialog} from 'components'; -import {DialogState} from 'types/common/store.d'; - -const useDialog = ({onCloseHandler, onSubmitHandler}: Pick) => { - const dispatch = useDispatch(); - - const showDialog = (props: DialogState) => dispatch(showDialogComponent(props)); - - const hideDialog = () => dispatch(closeDialog()); - - const DialogComponent = useCallback(() => ( - - ), []); - - return { - DialogComponent, - showDialog, - hideDialog, - }; -}; - -export default useDialog; diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index 229315380..997979c0d 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -9,7 +9,7 @@ import EnforceConnectedAccountModal from 'components/enforceConnectedAccountModa import MSTeamsAppManifestSetting from 'components/appManifestSetting'; import ListConnectedUsers from 'components/getConnectedUsersSetting'; -import {RhsTitle} from 'components'; +import {LinkChannelModal, RhsTitle} from 'components'; import {Rhs} from 'containers'; @@ -17,7 +17,7 @@ import {pluginTitle} from 'constants/common.constants'; import {iconUrl} from 'constants/illustrations.constants'; -import {handleConnect, handleDisconnect} from 'websocket'; +import {handleConnect, handleDisconnect, handleLink, handleModalLink, handleUnlinkChannels} from 'websocket'; import manifest from './manifest'; @@ -31,6 +31,7 @@ export default class Plugin { public async initialize(registry: PluginRegistry, store: Store>>) { registry.registerReducer(reducer); registry.registerRootComponent(App); + registry.registerRootComponent(LinkChannelModal); // @see https://developers.mattermost.com/extend/plugins/webapp/reference/ this.enforceConnectedAccountId = registry.registerRootComponent(EnforceConnectedAccountModal); @@ -53,12 +54,16 @@ export default class Plugin { registry.registerWebSocketEventHandler(`custom_${manifest.id}_connect`, handleConnect(store)); registry.registerWebSocketEventHandler(`custom_${manifest.id}_disconnect`, handleDisconnect(store)); + registry.registerWebSocketEventHandler(`custom_${manifest.id}_unlink`, handleUnlinkChannels(store)); + registry.registerWebSocketEventHandler(`custom_${manifest.id}_link_channels`, handleModalLink(store)); + registry.registerWebSocketEventHandler(`custom_${manifest.id}_link`, handleLink(store)); } } declare global { interface Window { - registerPlugin(id: string, plugin: Plugin): void, + registerPlugin(id: string, plugin: Plugin): void; + Components: any; } } diff --git a/webapp/src/reducers/dialog/index.ts b/webapp/src/reducers/dialog/index.ts deleted file mode 100644 index a38ea4c88..000000000 --- a/webapp/src/reducers/dialog/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -import {PayloadAction, createSlice} from '@reduxjs/toolkit'; - -import {DialogState} from 'types/common/store.d'; - -const initialState: DialogState = { - description: '', - destructive: false, - show: false, - primaryButtonText: '', - secondaryButtonText: '', - isLoading: false, - title: '', -}; - -export const dialogSlice = createSlice({ - name: 'dialogSlice', - initialState, - reducers: { - showDialog: (state, {payload}: PayloadAction) => { - state.show = true; - state.description = payload.description; - state.destructive = payload.destructive; - state.isLoading = payload.isLoading; - state.primaryButtonText = payload.primaryButtonText; - state.secondaryButtonText = payload.secondaryButtonText; - state.title = payload.title; - }, - closeDialog: (state) => { - state.show = false; - }, - - }, -}); - -export const {showDialog, closeDialog} = dialogSlice.actions; - -export default dialogSlice.reducer; diff --git a/webapp/src/reducers/index.ts b/webapp/src/reducers/index.ts index 4436d1083..6597c8b1a 100644 --- a/webapp/src/reducers/index.ts +++ b/webapp/src/reducers/index.ts @@ -5,16 +5,16 @@ import {msTeamsPluginApi} from 'services'; import apiRequestCompletionSlice from 'reducers/apiRequest'; import connectedStateSlice from 'reducers/connectedState'; import snackbarSlice from 'reducers/snackbar'; -import dialogSlice from 'reducers/dialog'; import rhsLoadingSlice from 'reducers/spinner'; import linkModalSlice from 'reducers/linkModal'; +import refetchSlice from 'reducers/refetchState'; export default combineReducers({ apiRequestCompletionSlice, connectedStateSlice, snackbarSlice, - dialogSlice, rhsLoadingSlice, linkModalSlice, + refetchSlice, [msTeamsPluginApi.reducerPath]: msTeamsPluginApi.reducer, }); diff --git a/webapp/src/reducers/linkModal/index.ts b/webapp/src/reducers/linkModal/index.ts index 5e8ad9496..009ff50d6 100644 --- a/webapp/src/reducers/linkModal/index.ts +++ b/webapp/src/reducers/linkModal/index.ts @@ -1,10 +1,13 @@ import {PayloadAction, createSlice} from '@reduxjs/toolkit'; -import {DialogState, ModalState} from 'types/common/store.d'; +import {ModalState} from 'types/common/store.d'; const initialState: ModalState = { show: false, isLoading: false, + mmChannel: '', + msChannel: '', + msTeam: '', }; export const linkModalSlice = createSlice({ @@ -20,9 +23,21 @@ export const linkModalSlice = createSlice({ setLinkModalLoading: (state, {payload}: PayloadAction) => { state.isLoading = payload; }, + preserveState: (state, {payload}: PayloadAction>) => { + state.mmChannel = payload.mmChannel; + state.msChannel = payload.msChannel; + state.msTeam = payload.msTeam; + }, + resetState: (state) => { + state.show = false; + state.isLoading = false; + state.mmChannel = ''; + state.msChannel = ''; + state.msTeam = ''; + }, }, }); -export const {showLinkModal, hideLinkModal, setLinkModalLoading} = linkModalSlice.actions; +export const {showLinkModal, hideLinkModal, setLinkModalLoading, preserveState, resetState} = linkModalSlice.actions; export default linkModalSlice.reducer; diff --git a/webapp/src/reducers/refetchState/index.ts b/webapp/src/reducers/refetchState/index.ts new file mode 100644 index 000000000..50cb6b74d --- /dev/null +++ b/webapp/src/reducers/refetchState/index.ts @@ -0,0 +1,24 @@ +import {createSlice} from '@reduxjs/toolkit'; + +import {RefetchState} from 'types/common/store.d'; + +const initialState: RefetchState = { + refetch: false, +}; + +export const refetchSlice = createSlice({ + name: 'refetch', + initialState, + reducers: { + refetch: (state) => { + state.refetch = true; + }, + resetRefetch: (state) => { + state.refetch = false; + }, + }, +}); + +export const {refetch, resetRefetch} = refetchSlice.actions; + +export default refetchSlice.reducer; diff --git a/webapp/src/selectors/index.tsx b/webapp/src/selectors/index.tsx index 7d056d175..db140084b 100644 --- a/webapp/src/selectors/index.tsx +++ b/webapp/src/selectors/index.tsx @@ -1,4 +1,6 @@ -import {ApiRequestCompletionState, ConnectedState, DialogState, ModalState, ReduxState, SnackbarState} from 'types/common/store.d'; +import {TeamsState} from 'mattermost-redux/types/teams'; + +import {ApiRequestCompletionState, ConnectedState, ModalState, ReduxState, RefetchState, SnackbarState} from 'types/common/store.d'; const getPluginState = (state: ReduxState) => state['plugins-com.mattermost.msteams-sync']; @@ -8,10 +10,10 @@ export const getConnectedState = (state: ReduxState): ConnectedState => getPlugi export const getSnackbarState = (state: ReduxState): SnackbarState => getPluginState(state).snackbarSlice; -export const getDialogState = (state: ReduxState): DialogState => getPluginState(state).dialogSlice; - export const getIsRhsLoading = (state: ReduxState): {isRhsLoading: boolean} => getPluginState(state).rhsLoadingSlice; -export const getCurrentTeam = (state: ReduxState): string => state.entities.teams.currentTeamId; +export const getCurrentTeam = (state: ReduxState): TeamsState => state.entities.teams; export const getLinkModalState = (state: ReduxState): ModalState => getPluginState(state).linkModalSlice; + +export const getRefetchState = (state: ReduxState): RefetchState => getPluginState(state).refetchSlice; diff --git a/webapp/src/services/index.ts b/webapp/src/services/index.ts index 797c7e739..b3e361163 100644 --- a/webapp/src/services/index.ts +++ b/webapp/src/services/index.ts @@ -64,5 +64,20 @@ export const msTeamsPluginApi = createApi({ params: {...params}, }), }), + [pluginApiServiceConfigs.linkChannels.apiServiceName]: builder.query({ + query: (payload) => ({ + url: pluginApiServiceConfigs.linkChannels.path, + method: pluginApiServiceConfigs.linkChannels.method, + body: payload, + responseHandler: (res) => res.text(), + }), + }), + [pluginApiServiceConfigs.unlinkChannel.apiServiceName]: builder.query({ + query: ({channelId}) => ({ + url: pluginApiServiceConfigs.unlinkChannel.path.replace('{channel_id}', channelId), + method: pluginApiServiceConfigs.unlinkChannel.method, + responseHandler: (res) => res.text(), + }), + }), }), }); diff --git a/webapp/src/styles/_variables.scss b/webapp/src/styles/_variables.scss index 64692a2e3..42d53b2cb 100644 --- a/webapp/src/styles/_variables.scss +++ b/webapp/src/styles/_variables.scss @@ -2,7 +2,7 @@ $display-styles: flex, none, block; // margins, paddings and gaps -$sizes: 0, 4, 6, 8, 10, 12, 16, 18, 24, 20, 30, 32, 40, 100, auto; +$sizes: 0, 4, 6, 8, 10, 12, 16, 18, 24, 20, 30, 32, 40, 62, 100, auto; // font weights $font-weights: 300, 400, 500, 600, 700, 800; diff --git a/webapp/src/types/common/payload.d.ts b/webapp/src/types/common/payload.d.ts index 503aa17a7..89e0d6457 100644 --- a/webapp/src/types/common/payload.d.ts +++ b/webapp/src/types/common/payload.d.ts @@ -14,3 +14,10 @@ type SearchParams = PaginationQueryParams & { type SearchMSChannelsParams = SearchParams & { teamId: string; } + +type LinkChannelsPayload = { + mattermostTeamID: string, + mattermostChannelID: string, + msTeamsTeamID: string, + msTeamsChannelID: string, +} diff --git a/webapp/src/types/common/service.d.ts b/webapp/src/types/common/service.d.ts index aff2b7e2b..b96016763 100644 --- a/webapp/src/types/common/service.d.ts +++ b/webapp/src/types/common/service.d.ts @@ -1,6 +1,6 @@ type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; -type PluginApiServiceName = 'needsConnect' | 'connect' | 'whitelistUser' | 'getLinkedChannels' | 'disconnectUser' | 'searchMSTeams' | 'searchMSChannels'; +type PluginApiServiceName = 'needsConnect' | 'connect' | 'whitelistUser' | 'getLinkedChannels' | 'disconnectUser' | 'searchMSTeams' | 'searchMSChannels' | 'linkChannels' | 'unlinkChannel'; type PluginApiService = { path: string, @@ -13,4 +13,4 @@ type APIError = { message: string, } -type APIRequestPayload = SearchParams | void; +type APIRequestPayload = LinkChannelsPayload | SearchParams | UnlinkChannelParams | void; diff --git a/webapp/src/types/common/store.d.ts b/webapp/src/types/common/store.d.ts index bb63969a4..fc07350de 100644 --- a/webapp/src/types/common/store.d.ts +++ b/webapp/src/types/common/store.d.ts @@ -2,8 +2,7 @@ import {BaseQueryFn, FetchArgs, FetchBaseQueryError, FetchBaseQueryMeta} from '@ import {GlobalState} from 'mattermost-redux/types/store'; -import {DialogProps} from '@brightscout/mattermost-ui-library'; - +import {DialogProps, ListItemType} from '@brightscout/mattermost-ui-library'; import {ModalProps} from '@brightscout/mattermost-ui-library/build/components/Modal'; import {SnackbarColor} from 'components/Snackbar/Snackbar.types'; @@ -38,11 +37,14 @@ type SnackbarState = { type SnackbarActionPayload = Pick; -type DialogState = Pick & { - isLoading?: boolean -} - type ModalState = { show?: boolean; isLoading?: boolean; + mmChannel: string; + msTeam: string; + msChannel: string; } + +type RefetchState = { + refetch: boolean; +}; diff --git a/webapp/src/websocket/index.ts b/webapp/src/websocket/index.ts index d01d61d80..09d247f4d 100644 --- a/webapp/src/websocket/index.ts +++ b/webapp/src/websocket/index.ts @@ -3,6 +3,8 @@ import {Action, Store} from 'redux'; import {GlobalState} from 'mattermost-redux/types/store'; import {setConnected} from 'reducers/connectedState'; +import {showLinkModal} from 'reducers/linkModal'; +import {refetch} from 'reducers/refetchState'; export function handleConnect(store: Store>>) { return (msg: WebsocketEventParams) => { @@ -17,3 +19,21 @@ export function handleDisconnect(store: Store>>) { + return (_: WebsocketEventParams) => { + store.dispatch(refetch() as Action); + }; +} + +export function handleModalLink(store: Store>>) { + return (_: WebsocketEventParams) => { + store.dispatch(showLinkModal() as Action); + }; +} + +export function handleLink(store: Store>>) { + return (_: WebsocketEventParams) => { + store.dispatch(refetch() as Action); + }; +}