mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
[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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user