[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 <build@mattermost.com>
This commit is contained in:
Nick Misasi
2024-10-07 10:55:23 -04:00
committed by GitHub
parent 9b6d2be129
commit 64a6e3a120
11 changed files with 232 additions and 57 deletions

View File

@@ -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);
}

View File

@@ -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(
<Provider store={store}>
<ChannelController shouldRenderCenterChannel={true}/>
</Provider>,
@@ -71,7 +72,7 @@ describe('ChannelController', () => {
const store = mockStore(mockState);
mockState.entities.general.config.EnableUserStatuses = 'false';
render(
renderWithContext(
<Provider store={store}>
<ChannelController shouldRenderCenterChannel={true}/>
</Provider>,

View File

@@ -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 (
<>
<CRTPostsChannelResetWatcher/>
<QueryParamActionController/>
<Sidebar/>
<div
id='channel_view'

View File

@@ -1,38 +1,31 @@
.NoPermissionsView {
padding-top: 20px;
&__header {
display: flex;
align-items: flex-start;
align-self: stretch;
padding: var(--spacing-xxl, 24px) 32px;
gap: 10px;
}
&__body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.icon-close {
position: absolute;
top: 20px;
right: 15px;
padding: 0;
border: none;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
background: none;
color: inherit;
color: rgba(var(--center-channel-color-rgb), 0.64);
cursor: pointer;
font: inherit;
font-size: 24px;
outline: inherit;
}
margin-bottom: var(--spacing-l, 12px);
}
&__title {
margin: 10px 0 22px;
font-family: 'Metropolis', sans-serif;
font-size: 22px;
font-weight: 600;
text-align: center;
}
&__description {
margin-bottom: 32px;
font-size: 14px;
text-align: left;
}
}
}

View File

@@ -12,35 +12,36 @@ import './no_permissions_view.scss';
type Props = {
footerClass: string;
onDone: () => void;
title?: string;
description?: string;
}
export default function NoPermissionsView(props: Props) {
return (
<>
<Modal.Header
className='NoPermissionsView__header'
closeButton={true}
>
<Modal.Title className='NoPermissionsView__title'>
<FormattedMessage
id='invite_modal.no_permissions.title'
defaultMessage='Unable to invite people'
/>
</Modal.Title>
</Modal.Header>
<Modal.Body>
<div className='NoPermissionsView__body'>
<button
className='icon icon-close'
aria-label='Close'
title='Close'
onClick={props.onDone}
/>
<AccessDeniedSvg
width={120}
height={100}
/>
<div className='NoPermissionsView__title'>
<FormattedMessage
id='invite_modal.no_permissions.title'
defaultMessage='Unable to continue'
/>
</div>
<div className='NoPermissionsView__description'>
<FormattedMessage
id='invite_modal.no_permissions.description'
defaultMessage='You do not have permissions to add users or guests. If this seems like an error, please reach out to your system administrator.'
/>
</div>
<AccessDeniedSvg
width={211}
height={156}
/>
</div>
</Modal.Body>
<Modal.Footer className={props.footerClass}>

View File

@@ -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<History, [any]> {
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(
<MemoryRouter initialEntries={['/?action=open_invitation_modal']}>
<Route
path='/'
component={QueryParamActionController}
/>
</MemoryRouter>,
);
expect(mockDispatch).toHaveBeenCalledWith(
openModal({
modalId: 'INVITATION',
dialogType: expect.any(Function),
}),
);
});
it('should not dispatch any action when action query parameter is not present', () => {
renderWithContext(
<MemoryRouter initialEntries={['/']}>
<Route
path='/'
component={QueryParamActionController}
/>
</MemoryRouter>,
);
expect(mockDispatch).not.toHaveBeenCalled();
});
it('should not dispatch any action when action query parameter is not in list', () => {
renderWithContext(
<MemoryRouter initialEntries={['/?action=invalid_action']}>
<Route
path='/'
component={QueryParamActionController}
/>
</MemoryRouter>,
);
expect(mockDispatch).not.toHaveBeenCalled();
});
it('should remove the action query parameter after dispatching the action', () => {
renderWithContext(
<MemoryRouter initialEntries={['/?action=open_invitation_modal']}>
<Route
path='/'
component={QueryParamActionController}
/>
</MemoryRouter>,
);
expect(mockDispatch).toHaveBeenCalledWith(
openModal({
modalId: 'INVITATION',
dialogType: expect.any(Function),
}),
);
expect(mockHistory.replace).toHaveBeenCalledWith({
search: '',
});
});
});

View File

@@ -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<any>;
}
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;

View File

@@ -99,12 +99,12 @@ export function registerCustomPostRenderer(type: string, component: any, id: str
};
}
export function redirectToOnboardingOrDefaultTeam(history: History): ThunkActionFunc<void> {
export function redirectToOnboardingOrDefaultTeam(history: History, searchParams?: URLSearchParams): ThunkActionFunc<void> {
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);
};
}

View File

@@ -317,7 +317,7 @@ export default class Root extends React.PureComponent<Props, State> {
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');
}

View File

@@ -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]);

View File

@@ -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",