From 64a6e3a1202ec3b505f91027c4e5cdf52f7795d5 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Mon, 7 Oct 2024 10:55:23 -0400 Subject: [PATCH] [CLD-8377] Add ability to open invitation modal from a query parameter (#28315) * Add query_param_action_controller, allowing actions to be performed based on query parameters when webapp is loaded * Actually git add the new component * undo changes to package-lock * Change render to renderWithContext * Move into channel_controller * Fix tests * Updates to RootRedirect component and redirectUserToDefaultTeam * Grab another entry point * Adjust font for title of no permissions modal, fix default text * Fix linter --------- Co-authored-by: Mattermost Build --- .../channels/src/actions/global_actions.tsx | 23 +++- .../channel_controller.test.tsx | 7 +- .../channel_layout/channel_controller.tsx | 2 + .../invitation_modal/no_permissions_view.scss | 35 +++--- .../invitation_modal/no_permissions_view.tsx | 33 ++--- .../query_param_action_controller.test.tsx | 116 ++++++++++++++++++ .../query_param_action_controller.tsx | 50 ++++++++ .../src/components/root/actions/index.ts | 12 +- webapp/channels/src/components/root/root.tsx | 2 +- .../root/root_redirect/root_redirect.tsx | 7 +- webapp/channels/src/i18n/en.json | 2 +- 11 files changed, 232 insertions(+), 57 deletions(-) create mode 100644 webapp/channels/src/components/query_param_actions/query_param_action_controller.test.tsx create mode 100644 webapp/channels/src/components/query_param_actions/query_param_action_controller.tsx diff --git a/webapp/channels/src/actions/global_actions.tsx b/webapp/channels/src/actions/global_actions.tsx index f1636b810b..593966eda5 100644 --- a/webapp/channels/src/actions/global_actions.tsx +++ b/webapp/channels/src/actions/global_actions.tsx @@ -329,7 +329,18 @@ export async function getTeamRedirectChannelIfIsAccesible(user: UserProfile, tea return null; } -export async function redirectUserToDefaultTeam() { +function historyPushWithQueryParams(path: string, queryParams?: URLSearchParams) { + if (queryParams) { + getHistory().push({ + pathname: path, + search: queryParams.toString(), + }); + } else { + getHistory().push(path); + } +} + +export async function redirectUserToDefaultTeam(searchParams?: URLSearchParams) { let state = getState(); // Assume we need to load the user if they don't have any team memberships loaded or the user loaded @@ -356,11 +367,11 @@ export async function redirectUserToDefaultTeam() { const teams = getActiveTeamsList(state); if (teams.length === 0) { if (isUserFirstAdmin && onboardingFlowEnabled) { - getHistory().push('/preparing-workspace'); + historyPushWithQueryParams('/preparing-workspace', searchParams); return; } - getHistory().push('/select_team'); + historyPushWithQueryParams('/select_team', searchParams); return; } @@ -373,7 +384,7 @@ export async function redirectUserToDefaultTeam() { const channel = await getTeamRedirectChannelIfIsAccesible(user, team); if (channel) { dispatch(selectChannel(channel.id)); - getHistory().push(`/${team.name}/channels/${channel.name}`); + historyPushWithQueryParams(`/${team.name}/channels/${channel.name}`, searchParams); return; } } @@ -385,10 +396,10 @@ export async function redirectUserToDefaultTeam() { const channel = await getTeamRedirectChannelIfIsAccesible(user, myTeam); // eslint-disable-line no-await-in-loop if (channel) { dispatch(selectChannel(channel.id)); - getHistory().push(`/${myTeam.name}/channels/${channel.name}`); + historyPushWithQueryParams(`/${myTeam.name}/channels/${channel.name}`, searchParams); return; } } - getHistory().push('/select_team'); + historyPushWithQueryParams('/select_team', searchParams); } diff --git a/webapp/channels/src/components/channel_layout/channel_controller.test.tsx b/webapp/channels/src/components/channel_layout/channel_controller.test.tsx index 6f53beb10c..d5907aa06e 100644 --- a/webapp/channels/src/components/channel_layout/channel_controller.test.tsx +++ b/webapp/channels/src/components/channel_layout/channel_controller.test.tsx @@ -1,12 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {render, act} from '@testing-library/react'; +import {act} from '@testing-library/react'; import React from 'react'; import {Provider} from 'react-redux'; import * as actions from 'actions/status_actions'; +import {renderWithContext} from 'tests/react_testing_utils'; import mockStore from 'tests/test_store'; import Constants from 'utils/constants'; import {TestHelper} from 'utils/test_helper'; @@ -54,7 +55,7 @@ describe('ChannelController', () => { mockState.entities.general.config.EnableUserStatuses = 'true'; const store = mockStore(mockState); - render( + renderWithContext( , @@ -71,7 +72,7 @@ describe('ChannelController', () => { const store = mockStore(mockState); mockState.entities.general.config.EnableUserStatuses = 'false'; - render( + renderWithContext( , diff --git a/webapp/channels/src/components/channel_layout/channel_controller.tsx b/webapp/channels/src/components/channel_layout/channel_controller.tsx index 7836c213ad..0e962e0ce9 100644 --- a/webapp/channels/src/components/channel_layout/channel_controller.tsx +++ b/webapp/channels/src/components/channel_layout/channel_controller.tsx @@ -13,6 +13,7 @@ import {addVisibleUsersInCurrentChannelAndSelfToStatusPoll} from 'actions/status import {makeAsyncComponent} from 'components/async_load'; import CenterChannel from 'components/channel_layout/center_channel'; import LoadingScreen from 'components/loading_screen'; +import QueryParamActionController from 'components/query_param_actions/query_param_action_controller'; import Sidebar from 'components/sidebar'; import CRTPostsChannelResetWatcher from 'components/threading/channel_threads/posts_channel_reset_watcher'; import UnreadsStatusHandler from 'components/unreads_status_handler'; @@ -69,6 +70,7 @@ export default function ChannelController(props: Props) { return ( <> +
void; + title?: string; + description?: string; } export default function NoPermissionsView(props: Props) { return ( <> + + + + +
-
diff --git a/webapp/channels/src/components/query_param_actions/query_param_action_controller.test.tsx b/webapp/channels/src/components/query_param_actions/query_param_action_controller.test.tsx new file mode 100644 index 0000000000..4243a7a5b1 --- /dev/null +++ b/webapp/channels/src/components/query_param_actions/query_param_action_controller.test.tsx @@ -0,0 +1,116 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useDispatch} from 'react-redux'; +import {MemoryRouter, Route, useHistory} from 'react-router-dom'; + +import {openModal} from 'actions/views/modals'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import QueryParamActionController from './query_param_action_controller'; + +// Mock react-redux since we just care about calling logic +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), +})); + +jest.mock('actions/views/modals', () => ({ + openModal: jest.fn(), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: jest.fn(), +})); + +describe('QueryParamActionController', () => { + let mockDispatch: jest.Mock; + + // Define a custom type for mockHistory that includes the replace method + interface MockHistory extends jest.Mock { + replace: jest.Mock; + } + let mockHistory: MockHistory; + + beforeEach(() => { + mockDispatch = jest.fn(); + (useDispatch as jest.Mock).mockReturnValue(mockDispatch); + mockHistory = { + replace: jest.fn(), + } as MockHistory; + (useHistory as jest.Mock).mockReturnValue(mockHistory); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should dispatch openModal for INVITATION modal ID when passed valid open_invitation_modal action', () => { + renderWithContext( + + + , + ); + + expect(mockDispatch).toHaveBeenCalledWith( + openModal({ + modalId: 'INVITATION', + dialogType: expect.any(Function), + }), + ); + }); + + it('should not dispatch any action when action query parameter is not present', () => { + renderWithContext( + + + , + ); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should not dispatch any action when action query parameter is not in list', () => { + renderWithContext( + + + , + ); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should remove the action query parameter after dispatching the action', () => { + renderWithContext( + + + , + ); + + expect(mockDispatch).toHaveBeenCalledWith( + openModal({ + modalId: 'INVITATION', + dialogType: expect.any(Function), + }), + ); + + expect(mockHistory.replace).toHaveBeenCalledWith({ + search: '', + }); + }); +}); diff --git a/webapp/channels/src/components/query_param_actions/query_param_action_controller.tsx b/webapp/channels/src/components/query_param_actions/query_param_action_controller.tsx new file mode 100644 index 0000000000..a2c7e64d0e --- /dev/null +++ b/webapp/channels/src/components/query_param_actions/query_param_action_controller.tsx @@ -0,0 +1,50 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect} from 'react'; +import {useDispatch} from 'react-redux'; +import {useHistory, useLocation} from 'react-router-dom'; + +import {openModal} from 'actions/views/modals'; + +import InvitationModal from 'components/invitation_modal'; + +import {ModalIdentifiers} from 'utils/constants'; + +import type {ModalData} from 'types/actions'; + +interface ActionMap { + [key: string]: ModalData; +} + +function QueryParamActionController() { + const location = useLocation(); + const dispatch = useDispatch(); + const history = useHistory(); + + const actionMap: ActionMap = { + open_invitation_modal: { + modalId: ModalIdentifiers.INVITATION, + dialogType: InvitationModal, + }, + }; + + useEffect(() => { + const searchParams = new URLSearchParams(location.search); + const action = searchParams.get('action'); + + if (action && actionMap[action]) { + dispatch(openModal(actionMap[action])); + + // Delete the action after it's been invoked so that it's not locked for subsequent refreshes + searchParams.delete('action'); + history.replace({ + search: searchParams.toString(), + }); + } + }, [location, actionMap]); + + return null; +} + +export default QueryParamActionController; diff --git a/webapp/channels/src/components/root/actions/index.ts b/webapp/channels/src/components/root/actions/index.ts index 3a4d91d09d..c101327fa5 100644 --- a/webapp/channels/src/components/root/actions/index.ts +++ b/webapp/channels/src/components/root/actions/index.ts @@ -99,12 +99,12 @@ export function registerCustomPostRenderer(type: string, component: any, id: str }; } -export function redirectToOnboardingOrDefaultTeam(history: History): ThunkActionFunc { +export function redirectToOnboardingOrDefaultTeam(history: History, searchParams?: URLSearchParams): ThunkActionFunc { return async (dispatch, getState) => { const state = getState(); const isUserAdmin = isCurrentUserSystemAdmin(state); if (!isUserAdmin) { - redirectUserToDefaultTeam(); + redirectUserToDefaultTeam(searchParams); return; } @@ -113,19 +113,19 @@ export function redirectToOnboardingOrDefaultTeam(history: History): ThunkAction const onboardingFlowEnabled = getIsOnboardingFlowEnabled(state); if (teams.length > 0 || !onboardingFlowEnabled) { - redirectUserToDefaultTeam(); + redirectUserToDefaultTeam(searchParams); return; } const firstAdminSetupComplete = await dispatch(getFirstAdminSetupComplete()); if (firstAdminSetupComplete?.data) { - redirectUserToDefaultTeam(); + redirectUserToDefaultTeam(searchParams); return; } const profilesResult = await dispatch(getProfiles(0, General.PROFILE_CHUNK_SIZE, {roles: General.SYSTEM_ADMIN_ROLE})); if (profilesResult.error) { - redirectUserToDefaultTeam(); + redirectUserToDefaultTeam(searchParams); return; } const currentUser = getCurrentUser(getState()); @@ -141,7 +141,7 @@ export function redirectToOnboardingOrDefaultTeam(history: History): ThunkAction return; } - redirectUserToDefaultTeam(); + redirectUserToDefaultTeam(searchParams); }; } diff --git a/webapp/channels/src/components/root/root.tsx b/webapp/channels/src/components/root/root.tsx index 6bba0340a2..7d57f60caa 100644 --- a/webapp/channels/src/components/root/root.tsx +++ b/webapp/channels/src/components/root/root.tsx @@ -317,7 +317,7 @@ export default class Root extends React.PureComponent { if (isUserAtRootRoute) { if (isMeRequested) { - this.props.actions.redirectToOnboardingOrDefaultTeam(this.props.history); + this.props.actions.redirectToOnboardingOrDefaultTeam(this.props.history, new URLSearchParams(this.props.location.search)); } else if (this.props.noAccounts) { this.props.history.push('/signup_user_complete'); } diff --git a/webapp/channels/src/components/root/root_redirect/root_redirect.tsx b/webapp/channels/src/components/root/root_redirect/root_redirect.tsx index 3a1db8624e..199aec748a 100644 --- a/webapp/channels/src/components/root/root_redirect/root_redirect.tsx +++ b/webapp/channels/src/components/root/root_redirect/root_redirect.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React, {useEffect} from 'react'; -import {Redirect, useHistory} from 'react-router-dom'; +import {Redirect, useHistory, useLocation} from 'react-router-dom'; import type {ActionResult} from 'mattermost-redux/types/actions'; @@ -21,6 +21,7 @@ export type Props = { export default function RootRedirect(props: Props) { const history = useHistory(); + const location = useLocation(); useEffect(() => { if (props.currentUserId) { @@ -30,11 +31,11 @@ export default function RootRedirect(props: Props) { if (firstAdminCompletedSignup.data === false && props.isFirstAdmin && !props.areThereTeams) { history.push('/preparing-workspace'); } else { - GlobalActions.redirectUserToDefaultTeam(); + GlobalActions.redirectUserToDefaultTeam(new URLSearchParams(location.search)); } }); } else { - GlobalActions.redirectUserToDefaultTeam(); + GlobalActions.redirectUserToDefaultTeam(new URLSearchParams(location.search)); } } }, [props.currentUserId, props.isElegibleForFirstAdmingOnboarding]); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 8386b1f190..0cb42ea4c5 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4011,7 +4011,7 @@ "invite_modal.invited_guests": "Guests", "invite_modal.invited_members": "Members", "invite_modal.no_permissions.description": "You do not have permissions to add users or guests. If this seems like an error, please reach out to your system administrator.", - "invite_modal.no_permissions.title": "Unable to continue", + "invite_modal.no_permissions.title": "Unable to invite people", "invite_modal.people": "people", "invite_modal.restricted_invite_guest.post_trial_description": "Collaborate with users outside of your organization while tightly controlling their access to channels and team members. Upgrade to the Professional plan to create unlimited user groups.", "invite_modal.restricted_invite_guest.post_trial_title": "Upgrade to invite guest",