MM 50960 - Add company name and invite members pages to preparing-workspace for self-hosted (#22838)

* MM-50960 - store system organization name

* restore the preparing workspace plugins and invite screens

* add back the page lines for the design

* add lines back and organize styles

* set back documentation to monorepo style and disable board as a product

* fix organization link and style skip button

* create team on organization name screen continue button click

* make sure there are not already created team and if so just update team name

* update the team display name if team has already been created

* cover error scenarios during team creation

* add pr feedback and add a couple of unit tests

* fix translation server error; make sure only update display name if it has changed in the form

* temp advances

* rewrite unit tests using react-testing library; fix unit tests

* fix translations

* make sure the launching workspace finish in cloud installations

* remove redundant validation

* fix unit tests

* remove unintended config value left after merge conflict
This commit is contained in:
Pablo Andrés Vélez Vidal 2023-04-19 15:31:47 +02:00 committed by yasserfaraazkhan
parent e1bad44e85
commit b073ab2e3a
47 changed files with 2248 additions and 107 deletions

View File

@ -665,7 +665,6 @@ const defaultServerConfig: AdminConfig = {
BoardsFeatureFlags: '',
BoardsDataRetention: false,
NormalizeLdapDNs: false,
UseCaseOnboarding: true,
GraphQL: false,
InsightsEnabled: true,
CommandPalette: false,

View File

@ -892,6 +892,7 @@ func TestCompleteOnboarding(t *testing.T) {
req := &model.CompleteOnboardingRequest{
InstallPlugins: []string{"testplugin2"},
Organization: "my-org",
}
t.Run("as a regular user", func(t *testing.T) {

View File

@ -28,6 +28,24 @@ func (a *App) markAdminOnboardingComplete(c *request.Context) *model.AppError {
}
func (a *App) CompleteOnboarding(c *request.Context, request *model.CompleteOnboardingRequest) *model.AppError {
isCloud := a.Srv().License() != nil && *a.Srv().License().Features.Cloud
if !isCloud && request.Organization == "" {
mlog.Error("No organization name provided for self hosted onboarding")
return model.NewAppError("CompleteOnboarding", "api.error_no_organization_name_provided_for_self_hosted_onboarding", nil, "", http.StatusBadRequest)
}
if request.Organization != "" {
err := a.Srv().Store().System().SaveOrUpdate(&model.System{
Name: model.SystemOrganizationName,
Value: request.Organization,
})
if err != nil {
// don't block onboarding because of that.
a.Log().Error("failed to save organization name", mlog.Err(err))
}
}
pluginsEnvironment := a.Channels().GetPluginsEnvironment()
if pluginsEnvironment == nil {
return a.markAdminOnboardingComplete(c)

View File

@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/server/v8/channels/app/request"
mm_model "github.com/mattermost/mattermost-server/server/v8/model"
)
func TestOnboardingSavesOrganizationName(t *testing.T) {
th := Setup(t)
defer th.TearDown()
err := th.App.CompleteOnboarding(&request.Context{}, &mm_model.CompleteOnboardingRequest{
Organization: "Mattermost In Tests",
})
require.Nil(t, err)
defer func() {
th.App.Srv().Store().System().PermanentDeleteByName(mm_model.SystemOrganizationName)
}()
sys, storeErr := th.App.Srv().Store().System().GetByName(mm_model.SystemOrganizationName)
require.NoError(t, storeErr)
require.Equal(t, "Mattermost In Tests", sys.Value)
}

View File

@ -1777,6 +1777,10 @@
"id": "api.error_get_first_admin_visit_marketplace_status",
"translation": "Error trying to retrieve the first admin visit marketplace status from the store."
},
{
"id": "api.error_no_organization_name_provided_for_self_hosted_onboarding",
"translation": "Error no organization name provided for self hosted onboarding."
},
{
"id": "api.error_set_first_admin_complete_setup",
"translation": "Error trying to save first admin complete setup in the store."

View File

@ -10,6 +10,7 @@ import (
// CompleteOnboardingRequest describes parameters of the requested plugin.
type CompleteOnboardingRequest struct {
Organization string `json:"organization"` // Organization is the name of the organization
InstallPlugins []string `json:"install_plugins"` // InstallPlugins is a list of plugins to be installed
}

View File

@ -16,6 +16,7 @@ const (
SystemAsymmetricSigningKeyKey = "AsymmetricSigningKey"
SystemPostActionCookieSecretKey = "PostActionCookieSecret"
SystemInstallationDateKey = "InstallationDate"
SystemOrganizationName = "OrganizationName"
SystemFirstServerRunTimestampKey = "FirstServerRunTimestamp"
SystemClusterEncryptionKey = "ClusterEncryptionKey"
SystemUpgradedFromTeId = "UpgradedFromTE"

View File

@ -14,7 +14,7 @@ import {Preferences} from 'mattermost-redux/constants';
import {getConfig, isPerformanceDebuggingEnabled} from 'mattermost-redux/selectors/entities/general';
import {getCurrentTeamId, getMyTeams, getTeam, getMyTeamMember, getTeamMemberships} from 'mattermost-redux/selectors/entities/teams';
import {getBool, isCollapsedThreadsEnabled, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUser, getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getCurrentUser, getCurrentUserId, isFirstAdmin} from 'mattermost-redux/selectors/entities/users';
import {getCurrentChannelStats, getCurrentChannelId, getMyChannelMember, getRedirectChannelNameForTeam, getChannelsNameMapInTeam, getAllDirectChannels, getChannelMessageCount} from 'mattermost-redux/selectors/entities/channels';
import {appsEnabled} from 'mattermost-redux/selectors/entities/apps';
import {ChannelTypes} from 'mattermost-redux/action_types';
@ -367,11 +367,19 @@ export async function redirectUserToDefaultTeam() {
return;
}
// if the user is the first admin
const isUserFirstAdmin = isFirstAdmin(state);
const locale = getCurrentLocale(state);
const teamId = LocalStorageStore.getPreviousTeamId(user.id);
let myTeams = getMyTeams(state);
if (myTeams.length === 0) {
if (isUserFirstAdmin) {
getHistory().push('/preparing-workspace');
return;
}
getHistory().push('/select_team');
return;
}

View File

@ -6,7 +6,6 @@ import {useIntl} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
import {useLocation, useHistory} from 'react-router-dom';
import {redirectUserToDefaultTeam} from 'actions/global_actions';
import {trackEvent} from 'actions/telemetry_actions.jsx';
import LaptopAlertSVG from 'components/common/svg_images_components/laptop_alert_svg';
@ -15,7 +14,6 @@ import LoadingScreen from 'components/loading_screen';
import {clearErrors, logError} from 'mattermost-redux/actions/errors';
import {verifyUserEmail, getMe} from 'mattermost-redux/actions/users';
import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {DispatchFunc} from 'mattermost-redux/types/actions';
@ -40,7 +38,6 @@ const DoVerifyEmail = () => {
const token = params.get('token') ?? '';
const loggedIn = Boolean(useSelector(getCurrentUserId));
const useCaseOnboarding = useSelector(getUseCaseOnboarding);
const [verifyStatus, setVerifyStatus] = useState(VerifyStatus.PENDING);
const [serverError, setServerError] = useState('');
@ -52,16 +49,11 @@ const DoVerifyEmail = () => {
const handleRedirect = () => {
if (loggedIn) {
if (useCaseOnboarding) {
// need info about whether admin or not,
// and whether admin has already completed
// first time onboarding. Instead of fetching and orchestrating that here,
// let the default root component handle it.
history.push('/');
return;
}
redirectUserToDefaultTeam();
// need info about whether admin or not,
// and whether admin has already completed
// first time onboarding. Instead of fetching and orchestrating that here,
// let the default root component handle it.
history.push('/');
return;
}

View File

@ -8,7 +8,6 @@ import {withRouter} from 'react-router-dom';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {GenericAction} from 'mattermost-redux/types/actions';
import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/teams';
import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences';
import {isFirstAdmin} from 'mattermost-redux/selectors/entities/users';
import {getUserGuideDropdownPluginMenuItems} from 'selectors/plugins';
@ -32,7 +31,6 @@ function mapStateToProps(state: GlobalState) {
teamUrl: getCurrentRelativeTeamUrl(state),
pluginMenuItems: getUserGuideDropdownPluginMenuItems(state),
isFirstAdmin: isFirstAdmin(state),
useCaseOnboarding: getUseCaseOnboarding(state),
};
}

View File

@ -34,7 +34,6 @@ describe('components/channel_header/components/UserGuideDropdown', () => {
},
pluginMenuItems: [],
isFirstAdmin: false,
useCaseOnboarding: false,
};
test('should match snapshot', () => {

View File

@ -13,7 +13,7 @@ import {UserProfile} from '@mattermost/types/users';
import {Client4} from 'mattermost-redux/client';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {getUseCaseOnboarding, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getTeamByName, getMyTeamMember} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {isSystemAdmin} from 'mattermost-redux/utils/user_utils';
@ -104,7 +104,6 @@ const Login = ({onCustomizeHeader}: LoginProps) => {
const currentUser = useSelector(getCurrentUser);
const experimentalPrimaryTeam = useSelector((state: GlobalState) => (ExperimentalPrimaryTeam ? getTeamByName(state, ExperimentalPrimaryTeam) : undefined));
const experimentalPrimaryTeamMember = useSelector((state: GlobalState) => getMyTeamMember(state, experimentalPrimaryTeam?.id ?? ''));
const useCaseOnboarding = useSelector(getUseCaseOnboarding);
const isCloud = useSelector(isCurrentLicenseCloud);
const graphQLEnabled = useSelector(isGraphQLEnabled);
@ -631,14 +630,12 @@ const Login = ({onCustomizeHeader}: LoginProps) => {
} else if (experimentalPrimaryTeamMember.team_id) {
// Only set experimental team if user is on that team
history.push(`/${ExperimentalPrimaryTeam}`);
} else if (useCaseOnboarding) {
} else {
// need info about whether admin or not,
// and whether admin has already completed
// first time onboarding. Instead of fetching and orchestrating that here,
// let the default root component handle it.
history.push('/');
} else {
redirectUserToDefaultTeam();
}
};

View File

@ -0,0 +1,80 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`InviteMembers component should match snapshot 1`] = `
<div>
<div
class="InviteMembers-body test-class"
>
<div
class="SingleColumnLayout"
style="width: 547px;"
>
<div>
<div
class="PageLine PageLine--no-left"
style="margin-bottom: 50px; margin-left: 50px; height: calc(25vh);"
/>
<div>
Previous step
</div>
<h1
class="PreparingWorkspaceTitle"
>
<span>
Invite your team members
</span>
</h1>
<p
class="PreparingWorkspaceDescription"
>
<span>
Collaboration is tough by yourself. Invite a few team members using the invitation link below.
</span>
</p>
<div
class="PreparingWorkspacePageBody"
>
<div
class="InviteMembersLink"
>
<input
aria-label="team invite link"
class="InviteMembersLink__input"
data-testid="shareLinkInput"
readonly=""
type="text"
value="https://my-org.mattermost.com/config/signup_user_complete/?id=1234"
/>
<button
class="InviteMembersLink__button"
data-testid="shareLinkInputButton"
>
<i
class="icon icon-link-variant"
/>
<span>
Copy Link
</span>
</button>
</div>
</div>
<div
class="InviteMembers__submit"
>
<button
class="primary-button"
>
<span>
Finish setup
</span>
</button>
</div>
<div
class="PageLine PageLine--no-left"
style="margin-top: 50px; margin-left: 50px; height: calc(30vh);"
/>
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/preparing-workspace/invite_members_link should match snapshot 1`] = `
<div>
<div
class="InviteMembersLink"
>
<input
aria-label="team invite link"
class="InviteMembersLink__input"
data-testid="shareLinkInput"
readonly=""
type="text"
value="https://invite-url.mattermost.com"
/>
<button
class="InviteMembersLink__button"
data-testid="shareLinkInputButton"
>
<i
class="icon icon-link-variant"
/>
<span>
Copy Link
</span>
</button>
</div>
</div>
`;

View File

@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/preparing-workspace/organization_status should match snapshot 1`] = `
<div
class="Organization__status"
/>
`;

View File

@ -5,7 +5,7 @@ import {connect} from 'react-redux';
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {Action} from 'mattermost-redux/types/actions';
import {checkIfTeamExists, createTeam} from 'mattermost-redux/actions/teams';
import {checkIfTeamExists, createTeam, updateTeam} from 'mattermost-redux/actions/teams';
import {getProfiles} from 'mattermost-redux/actions/users';
import PreparingWorkspace, {Actions} from './preparing_workspace';
@ -13,6 +13,7 @@ import PreparingWorkspace, {Actions} from './preparing_workspace';
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<Action>, Actions>({
updateTeam,
createTeam,
getProfiles,
checkIfTeamExists,

View File

@ -0,0 +1,51 @@
@import 'utils/mixins';
.InviteMembers-body {
display: flex;
// page width - channels preview width - progress dots width - people overlap width
max-width: calc(100vw - 600px - 120px - 30px);
.UsersEmailsInput {
max-width: 420px;
}
}
.InviteMembers {
&__submit {
display: flex;
align-items: center;
justify-content: flex-start;
}
}
@include simple-in-and-out-before("InviteMembers");
.ChannelsPreview--enter-from-after {
&-enter {
transform: translateX(-100vw);
}
&-enter-active {
transform: translateX(0);
transition: transform 300ms ease-in-out;
}
&-enter-done {
transform: translateX(0);
}
}
.ChannelsPreview--exit-to-after {
&-exit {
transform: translateX(0);
}
&-exit-active {
transform: translateX(-100vw);
transition: transform 300ms ease-in-out;
}
&-exit-done {
transform: translateX(-100vw);
}
}

View File

@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ComponentProps} from 'react';
import {render, screen, fireEvent} from '@testing-library/react';
import {withIntl} from 'tests/helpers/intl-test-helper';
import InviteMembers from './invite_members';
describe('InviteMembers component', () => {
let defaultProps: ComponentProps<any>;
beforeEach(() => {
defaultProps = {
disableEdits: false,
browserSiteUrl: 'https://my-org.mattermost.com',
formUrl: 'https://my-org.mattermost.com/signup',
teamInviteId: '1234',
className: 'test-class',
configSiteUrl: 'https://my-org.mattermost.com/config',
onPageView: jest.fn(),
previous: <div>{'Previous step'}</div>,
next: jest.fn(),
show: true,
transitionDirection: 'forward',
};
});
it('should match snapshot', () => {
const component = withIntl(<InviteMembers {...defaultProps}/>);
const {container} = render(component);
expect(container).toMatchSnapshot();
});
it('renders invite URL', () => {
const component = withIntl(<InviteMembers {...defaultProps}/>);
render(component);
const inviteLink = screen.getByTestId('shareLinkInput');
expect(inviteLink).toHaveAttribute(
'value',
'https://my-org.mattermost.com/config/signup_user_complete/?id=1234',
);
});
it('renders submit button with correct text', () => {
const component = withIntl(<InviteMembers {...defaultProps}/>);
render(component);
const button = screen.getByRole('button', {name: 'Finish setup'});
expect(button).toBeInTheDocument();
});
it('button is disabled when disableEdits is true', () => {
const component = withIntl(
<InviteMembers
{...defaultProps}
disableEdits={true}
/>,
);
render(component);
const button = screen.getByRole('button', {name: 'Finish setup'});
expect(button).toBeDisabled();
});
it('invokes next prop on button click', () => {
const component = withIntl(<InviteMembers {...defaultProps}/>);
render(component);
const button = screen.getByRole('button', {name: 'Finish setup'});
fireEvent.click(button);
expect(defaultProps.next).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,114 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo, useEffect} from 'react';
import {CSSTransition} from 'react-transition-group';
import {FormattedMessage} from 'react-intl';
import {Animations, mapAnimationReasonToClass, Form, PreparingWorkspacePageProps} from './steps';
import Title from './title';
import Description from './description';
import PageBody from './page_body';
import SingleColumnLayout from './single_column_layout';
import InviteMembersLink from './invite_members_link';
import PageLine from './page_line';
import './invite_members.scss';
type Props = PreparingWorkspacePageProps & {
disableEdits: boolean;
className?: string;
teamInviteId?: string;
formUrl: Form['url'];
configSiteUrl?: string;
browserSiteUrl: string;
}
const InviteMembers = (props: Props) => {
let className = 'InviteMembers-body';
if (props.className) {
className += ' ' + props.className;
}
useEffect(props.onPageView, []);
const inviteURL = useMemo(() => {
let urlBase = '';
if (props.configSiteUrl && !props.configSiteUrl.includes('localhost')) {
urlBase = props.configSiteUrl;
} else if (props.formUrl && !props.formUrl.includes('localhost')) {
urlBase = props.formUrl;
} else {
urlBase = props.browserSiteUrl;
}
return `${urlBase}/signup_user_complete/?id=${props.teamInviteId}`;
}, [props.teamInviteId, props.configSiteUrl, props.browserSiteUrl, props.formUrl]);
const description = (
<FormattedMessage
id={'onboarding_wizard.invite_members.description_link'}
defaultMessage='Collaboration is tough by yourself. Invite a few team members using the invitation link below.'
/>
);
const inviteInteraction = <InviteMembersLink inviteURL={inviteURL}/>;
return (
<CSSTransition
in={props.show}
timeout={Animations.PAGE_SLIDE}
classNames={mapAnimationReasonToClass('InviteMembers', props.transitionDirection)}
mountOnEnter={true}
unmountOnExit={true}
>
<div className={className}>
<SingleColumnLayout style={{width: 547}}>
<PageLine
style={{
marginBottom: '50px',
marginLeft: '50px',
height: 'calc(25vh)',
}}
noLeft={true}
/>
{props.previous}
<Title>
<FormattedMessage
id={'onboarding_wizard.invite_members.title'}
defaultMessage='Invite your team members'
/>
</Title>
<Description>
{description}
</Description>
<PageBody>
{inviteInteraction}
</PageBody>
<div className='InviteMembers__submit'>
<button
className='primary-button'
disabled={props.disableEdits}
onClick={props.next}
>
<FormattedMessage
id={'onboarding_wizard.invite_members.next_link'}
defaultMessage='Finish setup'
/>
</button>
</div>
<PageLine
style={{
marginTop: '50px',
marginLeft: '50px',
height: 'calc(30vh)',
}}
noLeft={true}
/>
</SingleColumnLayout>
</div>
</CSSTransition>
);
};
export default InviteMembers;

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,51 @@
.InviteMembersLink {
display: flex;
&__input {
height: 48px;
flex-grow: 1;
padding: 12px 14px;
border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.2);
border-right: 0;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.2);
border-left: 1px solid rgba(var(--center-channel-color-rgb), 0.2);
background: rgba(var(--center-channel-color-rgb), 0.04);
border-radius: 4px 0 0 4px;
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 16px;
}
&__button {
display: flex;
width: 180px;
max-width: 382px;
height: 48px;
flex-grow: 0;
align-items: center;
justify-content: center;
border: 1px solid var(--button-bg);
background: var(--center-channel-bg);
border-radius: 0 4px 4px 0;
color: var(--button-bg);
font-size: 16px;
font-weight: 600;
&:hover {
background: rgba(var(--button-bg-rgb), 0.08);
}
&:active {
background: rgba(var(--button-bg-rgb), 0.08);
}
span {
display: inline-block;
height: 24px;
margin-right: 9px;
}
svg {
fill: var(--button-bg);
}
}
}

View File

@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {render, screen, fireEvent} from '@testing-library/react';
import {trackEvent} from 'actions/telemetry_actions';
import InviteMembersLink from './invite_members_link';
import {withIntl} from 'tests/helpers/intl-test-helper';
jest.mock('actions/telemetry_actions', () => ({
trackEvent: jest.fn(),
}));
describe('components/preparing-workspace/invite_members_link', () => {
const inviteURL = 'https://invite-url.mattermost.com';
it('should match snapshot', () => {
const component = withIntl(<InviteMembersLink inviteURL={inviteURL}/>);
const {container} = render(component);
expect(container).toMatchSnapshot();
});
it('renders an input field with the invite URL', () => {
const component = withIntl(<InviteMembersLink inviteURL={inviteURL}/>);
render(component);
const input = screen.getByDisplayValue(inviteURL);
expect(input).toBeInTheDocument();
});
it('renders a button to copy the invite URL', () => {
const component = withIntl(<InviteMembersLink inviteURL={inviteURL}/>);
render(component);
const button = screen.getByRole('button', {name: /copy link/i});
expect(button).toBeInTheDocument();
});
it('calls the trackEvent function when the copy button is clicked', () => {
const component = withIntl(<InviteMembersLink inviteURL={inviteURL}/>);
render(component);
const button = screen.getByRole('button', {name: /copy link/i});
fireEvent.click(button);
expect(trackEvent).toHaveBeenCalledWith(
'first_admin_setup',
'admin_setup_click_copy_invite_link',
);
});
it('changes the button text to "Link Copied" when the URL is copied', () => {
const component = withIntl(<InviteMembersLink inviteURL={inviteURL}/>);
render(component);
const button = screen.getByRole('button', {name: /copy link/i});
const originalText = 'Copy Link';
const linkCopiedText = 'Link Copied';
expect(button).toHaveTextContent(originalText);
fireEvent.click(button);
expect(button).toHaveTextContent(linkCopiedText);
});
});

View File

@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import useCopyText from 'components/common/hooks/useCopyText';
import {trackEvent} from 'actions/telemetry_actions';
import './invite_members_link.scss';
type Props = {
inviteURL: string;
}
const InviteMembersLink = (props: Props) => {
const copyText = useCopyText({
trackCallback: () => trackEvent('first_admin_setup', 'admin_setup_click_copy_invite_link'),
text: props.inviteURL,
});
const intl = useIntl();
return (
<div className='InviteMembersLink'>
<input
className='InviteMembersLink__input'
type='text'
readOnly={true}
value={props.inviteURL}
aria-label={intl.formatMessage({
id: 'onboarding_wizard.invite_members.copy_link_input',
defaultMessage: 'team invite link',
})}
data-testid='shareLinkInput'
/>
<button
className='InviteMembersLink__button'
onClick={copyText.onClick}
data-testid='shareLinkInputButton'
>
{copyText.copiedRecently ? (
<>
<i className='icon icon-check'/>
<FormattedMessage
id='onboarding_wizard.invite_members.copied_link'
defaultMessage='Link Copied'
/>
</>
) : (
<>
<i className='icon icon-link-variant'/>
<FormattedMessage
id='onboarding_wizard.invite_members.copy_link'
defaultMessage='Copy Link'
/>
</>
)
}
</button>
</div>
);
};
export default InviteMembersLink;

View File

@ -0,0 +1,12 @@
@mixin input {
width: 452px;
padding: 12px 16px;
border: 2px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
font-size: 16px;
&:active,
&:focus {
border: 2px solid var(--button-bg);
}
}

View File

@ -0,0 +1,63 @@
@import 'utils/variables';
@import 'utils/mixins';
@import './mixins';
.Organization-body {
display: flex;
}
.Organization-form-wrapper {
position: relative;
}
.Organization-left-col {
width: 210px;
min-width: 210px;
}
.Organization-right-col {
display: flex;
flex-direction: column;
justify-content: center;
}
.Organization {
&__input {
@include input;
}
&__status {
display: flex;
align-items: center;
color: rgba(var(--center-channel-color-rgb), 0.72);
font-size: 12px;
&--error {
margin-top: 8px;
color: var(--dnd-indicator);
}
}
&__progress-path {
position: absolute;
top: -25px;
left: -55px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
text-align: center;
}
&__content {
margin-left: 200px;
}
}
@media screen and (max-width: 700px) {
.Organization-left-col {
display: none;
}
}
@include simple-in-and-out("Organization");

View File

@ -0,0 +1,206 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect, useRef, ChangeEvent} from 'react';
import {CSSTransition} from 'react-transition-group';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import debounce from 'lodash/debounce';
import OrganizationSVG from 'components/common/svg_images_components/organization-building_svg';
import QuickInput from 'components/quick_input';
import {trackEvent} from 'actions/telemetry_actions';
import {getTeams} from 'mattermost-redux/actions/teams';
import {getActiveTeamsList} from 'mattermost-redux/selectors/entities/teams';
import {Team} from '@mattermost/types/teams';
import {teamNameToUrl} from 'utils/url';
import Constants from 'utils/constants';
import OrganizationStatus, {TeamApiError} from './organization_status';
import {Animations, mapAnimationReasonToClass, Form, PreparingWorkspacePageProps} from './steps';
import PageLine from './page_line';
import Title from './title';
import Description from './description';
import PageBody from './page_body';
import './organization.scss';
type Props = PreparingWorkspacePageProps & {
organization: Form['organization'];
setOrganization: (organization: Form['organization']) => void;
className?: string;
createTeam: (OrganizationName: string) => Promise<{error: string | null; newTeam: Team | null}>;
updateTeam: (teamToUpdate: Team) => Promise<{error: string | null; updatedTeam: Team | null}>;
setInviteId: (inviteId: string) => void;
}
const reportValidationError = debounce(() => {
trackEvent('first_admin_setup', 'validate_organization_error');
}, 700, {leading: false});
const Organization = (props: Props) => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const [triedNext, setTriedNext] = useState(false);
const inputRef = useRef<HTMLInputElement>();
const validation = teamNameToUrl(props.organization || '');
const teamApiError = useRef<typeof TeamApiError | null>(null);
useEffect(props.onPageView, []);
const teams = useSelector(getActiveTeamsList);
useEffect(() => {
if (!teams) {
dispatch(getTeams(0, 60));
}
}, [teams]);
const setApiCallError = () => {
teamApiError.current = TeamApiError;
};
const updateTeamNameFromOrgName = async () => {
if (!inputRef.current?.value) {
return;
}
const name = inputRef.current?.value.trim();
const currentTeam = teams[0];
if (currentTeam && name && name !== currentTeam.display_name) {
const {error} = await props.updateTeam({...currentTeam, display_name: name});
if (error !== null) {
setApiCallError();
}
}
};
const createTeamFromOrgName = async () => {
if (!inputRef.current?.value) {
return;
}
const name = inputRef.current?.value.trim();
if (name) {
const {error, newTeam} = await props.createTeam(name);
if (error !== null || newTeam === null) {
props.setInviteId('');
setApiCallError();
return;
}
props.setInviteId(newTeam.invite_id);
}
};
const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => {
props.setOrganization(e.target.value);
teamApiError.current = null;
};
const onNext = (e?: React.KeyboardEvent | React.MouseEvent) => {
if (e && (e as React.KeyboardEvent).key) {
if ((e as React.KeyboardEvent).key !== Constants.KeyCodes.ENTER[0]) {
return;
}
}
if (!triedNext) {
setTriedNext(true);
}
// if there is already a team, maybe because a page reload, then just update the teamname
const thereIsAlreadyATeam = teams.length > 0;
teamApiError.current = null;
if (!validation.error && !thereIsAlreadyATeam) {
createTeamFromOrgName();
} else if (!validation.error && thereIsAlreadyATeam) {
updateTeamNameFromOrgName();
}
if (validation.error || teamApiError.current) {
reportValidationError();
return;
}
props.next?.();
};
let className = 'Organization-body';
if (props.className) {
className += ' ' + props.className;
}
return (
<CSSTransition
in={props.show}
timeout={Animations.PAGE_SLIDE}
classNames={mapAnimationReasonToClass('Organization', props.transitionDirection)}
mountOnEnter={true}
unmountOnExit={true}
>
<div className={className}>
<div className='Organization-right-col'>
<div className='Organization-form-wrapper'>
<div className='Organization__progress-path'>
<OrganizationSVG/>
<PageLine
style={{
marginTop: '5px',
height: 'calc(50vh)',
}}
noLeft={true}
/>
</div>
<div className='Organization__content'>
{props.previous}
<Title>
<FormattedMessage
id={'onboarding_wizard.organization.title'}
defaultMessage='Whats the name of your organization?'
/>
</Title>
<Description>
<FormattedMessage
id={'onboarding_wizard.organization.description'}
defaultMessage='Well use this to help personalize your workspace.'
/>
</Description>
<PageBody>
<QuickInput
placeholder={
formatMessage({
id: 'onboarding_wizard.organization.placeholder',
defaultMessage: 'Organization name',
})
}
className='Organization__input'
value={props.organization || ''}
onChange={(e) => handleOnChange(e)}
onKeyUp={onNext}
autoFocus={true}
ref={inputRef as unknown as any}
/>
{triedNext ? <OrganizationStatus error={validation.error || teamApiError.current}/> : null}
</PageBody>
<button
className='primary-button'
data-testid='continue'
onClick={onNext}
disabled={!props.organization}
>
<FormattedMessage
id={'onboarding_wizard.next'}
defaultMessage='Continue'
/>
</button>
</div>
</div>
</div>
</div>
</CSSTransition>
);
};
export default Organization;

View File

@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {render} from '@testing-library/react';
import {BadUrlReasons} from 'utils/url';
import OrganizationStatus, {TeamApiError} from './organization_status';
import {withIntl} from 'tests/helpers/intl-test-helper';
describe('components/preparing-workspace/organization_status', () => {
const defaultProps = {
error: null,
};
it('should match snapshot', () => {
const {container} = render(<OrganizationStatus {...defaultProps}/>);
expect(container.firstChild).toMatchSnapshot();
});
it('should render no error message when error prop is null', () => {
const {queryByText, container} = render(<OrganizationStatus {...defaultProps}/>);
expect((container.getElementsByClassName('Organization__status').length)).toBe(1);
expect(queryByText(/empty/i)).not.toBeInTheDocument();
expect(queryByText(/team api error/i)).not.toBeInTheDocument();
expect(queryByText(/length/i)).not.toBeInTheDocument();
expect(queryByText(/reserved/i)).not.toBeInTheDocument();
});
it('should render an error message for an empty organization name', () => {
const component = withIntl(<OrganizationStatus error={BadUrlReasons.Empty}/>);
const {getByText} = render(component);
expect(getByText(/You must enter an organization name/i)).toBeInTheDocument();
});
it('should render an error message for a team API error', () => {
const component = withIntl(<OrganizationStatus error={TeamApiError}/>);
const {getByText} = render(component);
expect(getByText(/There was an error, please try again/i)).toBeInTheDocument();
});
it('should render an error message for an organization name with invalid length', () => {
const component = withIntl(<OrganizationStatus error={BadUrlReasons.Length}/>);
const {getByText} = render(component);
expect(getByText(/Organization name must be between 2 and 64 characters/i)).toBeInTheDocument();
});
});

View File

@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {BadUrlReasons, UrlValidationCheck} from 'utils/url';
import Constants, {DocLinks} from 'utils/constants';
import ExternalLink from 'components/external_link';
export const TeamApiError = 'team_api_error';
const OrganizationStatus = (props: {error: (UrlValidationCheck['error'] | typeof TeamApiError | null)}): JSX.Element => {
let children = null;
let className = 'Organization__status';
if (props.error) {
className += ' Organization__status--error';
switch (props.error) {
case BadUrlReasons.Empty:
children = (
<FormattedMessage
id='onboarding_wizard.organization.empty'
defaultMessage='You must enter an organization name'
/>
);
break;
case TeamApiError:
children = (
<FormattedMessage
id='onboarding_wizard.organization.team_api_error'
defaultMessage='There was an error, please try again.'
/>
);
break;
case BadUrlReasons.Length:
children = (
<FormattedMessage
id='onboarding_wizard.organization.length'
defaultMessage='Organization name must be between {min} and {max} characters'
values={{
min: Constants.MIN_TEAMNAME_LENGTH,
max: Constants.MAX_TEAMNAME_LENGTH,
}}
/>
);
break;
case BadUrlReasons.Reserved:
children = (
<FormattedMessage
id='onboarding_wizard.organization.reserved'
defaultMessage='Organization name may not <a>start with a reserved word</a>.'
values={{
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
<ExternalLink
href={DocLinks.ABOUT_TEAMS}
target='_blank'
rel='noreferrer'
>
{chunks}
</ExternalLink>
),
}}
/>
);
break;
default:
children = (
<FormattedMessage
id='onboarding_wizard.organization.other'
defaultMessage='Invalid organization name: {reason}'
values={{
reason: props.error,
}}
/>
);
break;
}
}
return <div className={className}>{children}</div>;
};
export default OrganizationStatus;

View File

@ -0,0 +1,10 @@
.PageLine {
position: relative;
left: 100px;
width: 1px;
background-color: rgba(var(--center-channel-color-rgb), 0.24);
&--no-left {
left: initial;
}
}

View File

@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import './page_line.scss';
type Props = {
style?: Record<string, string>;
noLeft?: boolean;
}
const PageLine = (props: Props) => {
let className = 'PageLine';
if (props.noLeft) {
className += ' PageLine--no-left';
}
const styles: Record<string, string> = {};
if (props?.style) {
Object.assign(styles, props.style);
}
if (!styles.height) {
styles.height = '100vh';
}
if ((!props.style?.height && styles.height === '100vh') && !styles.marginTop) {
styles.marginTop = '50px';
}
return (
<div
className={className}
style={styles}
/>
);
};
export default PageLine;

View File

@ -4,6 +4,9 @@
margin-top: 24px;
}
.plugins-skip-btn {
margin-left: 8px;
}
// preempt cards wrapping
@media screen and (max-width: 900px) {
.Plugins-body {

View File

@ -21,15 +21,16 @@ import {Animations, mapAnimationReasonToClass, Form, PreparingWorkspacePageProps
import Title from './title';
import Description from './description';
import PageBody from './page_body';
import SingleColumnLayout from './single_column_layout';
import PageLine from './page_line';
import './plugins.scss';
type Props = PreparingWorkspacePageProps & {
options: Form['plugins'];
setOption: (option: keyof Form['plugins']) => void;
className?: string;
isSelfHosted: boolean;
}
const Plugins = (props: Props) => {
const {formatMessage} = useIntl();
@ -44,6 +45,34 @@ const Plugins = (props: Props) => {
if (props.className) {
className += ' ' + props.className;
}
let title = (
<FormattedMessage
id={'onboarding_wizard.cloud_plugins.title'}
defaultMessage='Welcome to Mattermost!'
/>
);
let description = (
<FormattedMessage
id={'onboarding_wizard.cloud_plugins.description'}
defaultMessage={'Mattermost is better when integrated with the tools your team uses for collaboration. Popular tools are below, select the ones your team uses and we\'ll add them to your workspace. Additional set up may be needed later.'}
/>
);
if (props.isSelfHosted) {
title = (
<FormattedMessage
id={'onboarding_wizard.self_hosted_plugins.title'}
defaultMessage='What tools do you use?'
/>
);
description = (
<FormattedMessage
id={'onboarding_wizard.self_hosted_plugins.description'}
defaultMessage={'Choose the tools you work with, and we\'ll add them to your workspace. Additional set up may be needed later.'}
/>
);
}
return (
<CSSTransition
in={props.show}
@ -54,26 +83,29 @@ const Plugins = (props: Props) => {
>
<div className={className}>
<SingleColumnLayout>
<PageLine
style={{
marginBottom: '50px',
marginLeft: '50px',
height: 'calc(25vh)',
}}
noLeft={true}
/>
{props.previous}
<Title>
<FormattedMessage
id={'onboarding_wizard.plugins.title'}
defaultMessage='Welcome to Mattermost!'
/>
<div className='subtitle'>
<CelebrateSVG/>
<FormattedMessage
id={'onboarding_wizard.plugins.subtitle'}
defaultMessage='(almost there!)'
/>
</div>
{title}
{!props.isSelfHosted && (
<div className='subtitle'>
<CelebrateSVG/>
<FormattedMessage
id={'onboarding_wizard.cloud_plugins.subtitle'}
defaultMessage='(almost there!)'
/>
</div>
)}
</Title>
<Description>
<FormattedMessage
id={'onboarding_wizard.plugins.description'}
defaultMessage={'Mattermost is better when integrated with the tools your team uses for collaboration. Popular tools are below, select the ones your team uses and we\'ll add them to your workspace. Additional set up may be needed later.'}
/>
</Description>
<Description>{description}</Description>
<PageBody>
<MultiSelectCards
size='small'
@ -166,15 +198,23 @@ const Plugins = (props: Props) => {
/>
</button>
<button
className='tertiary-button'
className='link-style plugins-skip-btn'
onClick={props.skip}
>
<FormattedMessage
id={'onboarding_wizard.skip'}
defaultMessage='Skip for now'
id={'onboarding_wizard.skip-button'}
defaultMessage='Skip'
/>
</button>
</div>
<PageLine
style={{
marginTop: '50px',
marginLeft: '50px',
height: 'calc(30vh)',
}}
noLeft={true}
/>
</SingleColumnLayout>
</div>
</CSSTransition>

View File

@ -63,6 +63,21 @@
.primary-button {
@include primary-button;
@include button-medium;
box-sizing: border-box;
border: 2px solid var(--button-bg);
}
.primary-button[disabled] {
box-sizing: border-box;
border: 2px solid rgba(var(--center-channel-color-rgb), 0.01);
}
.link-style {
@include link;
background: transparent;
font-size: 14px;
}
.child-page {
@ -70,6 +85,43 @@
position: absolute;
height: 100vh;
}
&__invite-members-illustration {
position: absolute;
top: 25%;
right: -651px;
animation-duration: 0.3s;
animation-fill-mode: forwards;
animation-timing-function: ease-in-out;
}
}
.enter {
animation-name: slideInRight;
}
.exit {
animation-name: slideOutRight;
}
@keyframes slideInRight {
from {
right: -651px;
}
to {
right: 0;
}
}
@keyframes slideOutRight {
from {
right: 0;
}
to {
right: -651px;
}
}
.PreparingWorkspacePageContainer {

View File

@ -1,23 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useCallback, useEffect, useRef} from 'react';
import React, {useState, useCallback, useEffect, useRef, useMemo} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {RouterProps} from 'react-router-dom';
import {useIntl} from 'react-intl';
import {FormattedMessage, useIntl} from 'react-intl';
import {GeneralTypes} from 'mattermost-redux/action_types';
import {General} from 'mattermost-redux/constants';
import {getFirstAdminSetupComplete as getFirstAdminSetupCompleteAction} from 'mattermost-redux/actions/general';
import {ActionResult} from 'mattermost-redux/types/actions';
import {Team} from '@mattermost/types/teams';
import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences';
import {isFirstAdmin} from 'mattermost-redux/selectors/entities/users';
import {getCurrentTeam, getMyTeams} from 'mattermost-redux/selectors/entities/teams';
import {getFirstAdminSetupComplete, getConfig} from 'mattermost-redux/selectors/entities/general';
import {getFirstAdminSetupComplete, getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {Client4} from 'mattermost-redux/client';
import Constants from 'utils/constants';
import {getSiteURL, teamNameToUrl} from 'utils/url';
import {makeNewTeam} from 'utils/team_utils';
import {pageVisited, trackEvent} from 'actions/telemetry_actions';
@ -35,10 +36,14 @@ import {
mapStepToPageView,
mapStepToSubmitFail,
PLUGIN_NAME_TO_ID_MAP,
mapStepToPrevious,
} from './steps';
import Organization from './organization';
import Plugins from './plugins';
import Progress from './progress';
import InviteMembers from './invite_members';
import InviteMembersIllustration from './invite_members_illustration';
import LaunchingWorkspace, {START_TRANSITIONING_OUT} from './launching_workspace';
import './preparing_workspace.scss';
@ -58,6 +63,7 @@ const WAIT_FOR_REDIRECT_TIME = 2000 - START_TRANSITIONING_OUT;
export type Actions = {
createTeam: (team: Team) => ActionResult;
updateTeam: (team: Team) => ActionResult;
checkIfTeamExists: (teamName: string) => ActionResult;
getProfiles: (page: number, perPage: number, options: Record<string, any>) => ActionResult;
}
@ -81,12 +87,16 @@ function makeSubmitFail(step: WizardStep) {
}
const trackSubmitFail = {
[WizardSteps.Organization]: makeSubmitFail(WizardSteps.Organization),
[WizardSteps.Plugins]: makeSubmitFail(WizardSteps.Plugins),
[WizardSteps.InviteMembers]: makeSubmitFail(WizardSteps.InviteMembers),
[WizardSteps.LaunchingWorkspace]: makeSubmitFail(WizardSteps.LaunchingWorkspace),
};
const onPageViews = {
[WizardSteps.Organization]: makeOnPageView(WizardSteps.Organization),
[WizardSteps.Plugins]: makeOnPageView(WizardSteps.Plugins),
[WizardSteps.InviteMembers]: makeOnPageView(WizardSteps.InviteMembers),
[WizardSteps.LaunchingWorkspace]: makeOnPageView(WizardSteps.LaunchingWorkspace),
};
@ -98,28 +108,35 @@ const PreparingWorkspace = (props: Props) => {
defaultMessage: 'Something went wrong. Please try again.',
});
const isUserFirstAdmin = useSelector(isFirstAdmin);
const useCaseOnboarding = useSelector(getUseCaseOnboarding);
const currentTeam = useSelector(getCurrentTeam);
const myTeams = useSelector(getMyTeams);
// In cloud instances created from portal,
// new admin user has a team in myTeams but not in currentTeam.
const team = currentTeam || myTeams?.[0];
let team = currentTeam || myTeams?.[0];
const config = useSelector(getConfig);
const pluginsEnabled = config.PluginsEnabled === 'true';
const showOnMountTimeout = useRef<NodeJS.Timeout>();
const configSiteUrl = config.SiteURL;
const isSelfHosted = useSelector(getLicense).Cloud !== 'true';
const stepOrder = [
isSelfHosted && WizardSteps.Organization,
pluginsEnabled && WizardSteps.Plugins,
isSelfHosted && WizardSteps.InviteMembers,
WizardSteps.LaunchingWorkspace,
].filter((x) => Boolean(x)) as WizardStep[];
// first steporder that is not false
const firstShowablePage = stepOrder[0];
const firstAdminSetupComplete = useSelector(getFirstAdminSetupComplete);
const [[mostRecentStep, currentStep], setStepHistory] = useState<[WizardStep, WizardStep]>([stepOrder[0], stepOrder[0]]);
const [submissionState, setSubmissionState] = useState<SubmissionState>(SubmissionStates.Presubmit);
const browserSiteUrl = useMemo(getSiteURL, []);
const [form, setForm] = useState({
...emptyForm,
});
@ -188,13 +205,44 @@ const PreparingWorkspace = (props: Props) => {
trackSubmitFail[redirectTo]();
}, []);
const createTeam = async (OrganizationName: string): Promise<{error: string | null; newTeam: Team | null}> => {
const data = await props.actions.createTeam(makeNewTeam(OrganizationName, teamNameToUrl(OrganizationName || '').url));
if (data.error) {
return {error: genericSubmitError, newTeam: null};
}
return {error: null, newTeam: data.data};
};
const updateTeam = async (teamToUpdate: Team): Promise<{error: string | null; updatedTeam: Team | null}> => {
const data = await props.actions.updateTeam(teamToUpdate);
if (data.error) {
return {error: genericSubmitError, updatedTeam: null};
}
return {error: null, updatedTeam: data.data};
};
const sendForm = async () => {
const sendFormStart = Date.now();
setSubmissionState(SubmissionStates.Submitting);
if (form.organization && !isSelfHosted) {
try {
const {error, newTeam} = await createTeam(form.organization);
if (error !== null) {
redirectWithError(WizardSteps.Organization, genericSubmitError);
return;
}
team = newTeam as Team;
} catch (e) {
redirectWithError(WizardSteps.Organization, genericSubmitError);
return;
}
}
// send plugins
const {skipped: skippedPlugins, ...pluginChoices} = form.plugins;
let pluginsToSetup: string[] = [];
if (!skippedPlugins) {
pluginsToSetup = Object.entries(pluginChoices).reduce(
(acc: string[], [k, v]): string[] => (v ? [...acc, PLUGIN_NAME_TO_ID_MAP[k as keyof Omit<Form['plugins'], 'skipped'>]] : acc), [],
@ -204,8 +252,10 @@ const PreparingWorkspace = (props: Props) => {
// This endpoint sets setup complete state, so we need to make this request
// even if admin skipped submitting plugins.
const completeSetupRequest = {
organization: form.organization,
install_plugins: pluginsToSetup,
};
try {
await Client4.completeSetup(completeSetupRequest);
dispatch({type: GeneralTypes.FIRST_ADMIN_COMPLETE_SETUP_RECEIVED, data: true});
@ -221,6 +271,7 @@ const PreparingWorkspace = (props: Props) => {
const sendFormEnd = Date.now();
const timeToWait = WAIT_FOR_REDIRECT_TIME - (sendFormEnd - sendFormStart);
if (timeToWait > 0) {
setTimeout(goToChannels, timeToWait);
} else {
@ -236,7 +287,8 @@ const PreparingWorkspace = (props: Props) => {
}, [submissionState]);
const adminRevisitedPage = firstAdminSetupComplete && submissionState === SubmissionStates.Presubmit;
const shouldRedirect = !isUserFirstAdmin || adminRevisitedPage || !useCaseOnboarding;
const shouldRedirect = !isUserFirstAdmin || adminRevisitedPage;
useEffect(() => {
if (shouldRedirect) {
props.history.push('/');
@ -256,6 +308,24 @@ const PreparingWorkspace = (props: Props) => {
return stepIndex > currentStepIndex ? Animations.Reasons.ExitToBefore : Animations.Reasons.ExitToAfter;
};
const goPrevious = useCallback((e?: React.KeyboardEvent | React.MouseEvent) => {
if (e && (e as React.KeyboardEvent).key) {
const key = (e as React.KeyboardEvent).key;
if (key !== Constants.KeyCodes.ENTER[0] && key !== Constants.KeyCodes.SPACE[0]) {
return;
}
}
if (submissionState !== SubmissionStates.Presubmit && submissionState !== SubmissionStates.SubmitFail) {
return;
}
const stepIndex = stepOrder.indexOf(currentStep);
if (stepIndex <= 0) {
return;
}
trackEvent('first_admin_setup', mapStepToPrevious(currentStep));
setStepHistory([currentStep, stepOrder[stepIndex - 1]]);
}, [currentStep]);
const skipPlugins = useCallback((skipped: boolean) => {
if (skipped === form.plugins.skipped) {
return;
@ -269,6 +339,46 @@ const PreparingWorkspace = (props: Props) => {
});
}, [form]);
const skipTeamMembers = useCallback((skipped: boolean) => {
if (skipped === form.teamMembers.skipped) {
return;
}
setForm({
...form,
teamMembers: {
...form.teamMembers,
skipped,
},
});
}, [form]);
const getInviteMembersAnimationClass = useCallback(() => {
if (currentStep === WizardSteps.InviteMembers) {
return 'enter';
} else if (mostRecentStep === WizardSteps.InviteMembers) {
return 'exit';
}
return '';
}, [currentStep]);
let previous: React.ReactNode = (
<div
onClick={goPrevious}
onKeyUp={goPrevious}
tabIndex={0}
className='PreparingWorkspace__previous'
>
<i className='icon-chevron-up'/>
<FormattedMessage
id={'onboarding_wizard.previous'}
defaultMessage='Previous'
/>
</div>
);
if (currentStep === firstShowablePage) {
previous = null;
}
return (
<div className='PreparingWorkspace PreparingWorkspaceContainer'>
{submissionState === SubmissionStates.SubmitFail && submitError && (
@ -291,17 +401,49 @@ const PreparingWorkspace = (props: Props) => {
transitionSpeed={Animations.PAGE_SLIDE}
/>
<div className='PreparingWorkspacePageContainer'>
<Organization
onPageView={onPageViews[WizardSteps.Organization]}
show={shouldShowPage(WizardSteps.Organization)}
next={makeNext(WizardSteps.Organization)}
transitionDirection={getTransitionDirection(WizardSteps.Organization)}
organization={form.organization || ''}
setOrganization={(organization: Form['organization']) => {
setForm({
...form,
organization,
});
}}
setInviteId={(inviteId: string) => {
setForm({
...form,
teamMembers: {
...form.teamMembers,
inviteId,
},
});
}}
className='child-page'
createTeam={createTeam}
updateTeam={updateTeam}
/>
<Plugins
isSelfHosted={isSelfHosted}
onPageView={onPageViews[WizardSteps.Plugins]}
previous={previous}
next={() => {
const pluginChoices = {...form.plugins};
delete pluginChoices.skipped;
setSubmissionState(SubmissionStates.UserRequested);
if (!isSelfHosted) {
setSubmissionState(SubmissionStates.UserRequested);
}
makeNext(WizardSteps.Plugins)(pluginChoices);
skipPlugins(false);
}}
skip={() => {
setSubmissionState(SubmissionStates.UserRequested);
if (!isSelfHosted) {
setSubmissionState(SubmissionStates.UserRequested);
}
makeNext(WizardSteps.Plugins, true)();
skipPlugins(true);
}}
@ -319,12 +461,40 @@ const PreparingWorkspace = (props: Props) => {
transitionDirection={getTransitionDirection(WizardSteps.Plugins)}
className='child-page'
/>
<InviteMembers
onPageView={onPageViews[WizardSteps.InviteMembers]}
next={() => {
skipTeamMembers(false);
const inviteMembersTracking = {
inviteCount: form.teamMembers.invites.length,
};
setSubmissionState(SubmissionStates.UserRequested);
makeNext(WizardSteps.InviteMembers)(inviteMembersTracking);
}}
skip={() => {
skipTeamMembers(true);
setSubmissionState(SubmissionStates.UserRequested);
makeNext(WizardSteps.InviteMembers, true)();
}}
previous={previous}
show={shouldShowPage(WizardSteps.InviteMembers)}
transitionDirection={getTransitionDirection(WizardSteps.InviteMembers)}
disableEdits={submissionState !== SubmissionStates.Presubmit && submissionState !== SubmissionStates.SubmitFail}
className='child-page'
teamInviteId={team?.invite_id || form.teamMembers.inviteId}
configSiteUrl={configSiteUrl}
formUrl={form.url}
browserSiteUrl={browserSiteUrl}
/>
<LaunchingWorkspace
onPageView={onPageViews[WizardSteps.LaunchingWorkspace]}
show={currentStep === WizardSteps.LaunchingWorkspace}
transitionDirection={getTransitionDirection(WizardSteps.LaunchingWorkspace)}
/>
</div>
<div className={`PreparingWorkspace__invite-members-illustration ${getInviteMembersAnimationClass()}`}>
<InviteMembersIllustration/>
</div>
</div>
);
};

View File

@ -4,5 +4,4 @@
height: 100vh;
flex-direction: column;
align-items: flex-start;
justify-content: center;
}

View File

@ -4,7 +4,9 @@
import deepFreeze from 'mattermost-redux/utils/deep_freeze';
export const WizardSteps = {
Organization: 'Organization',
Plugins: 'Plugins',
InviteMembers: 'InviteMembers',
LaunchingWorkspace: 'LaunchingWorkspace',
} as const;
@ -20,8 +22,12 @@ export const Animations = {
export function mapStepToNextName(step: WizardStep): string {
switch (step) {
case WizardSteps.Organization:
return 'admin_onboarding_next_organization';
case WizardSteps.Plugins:
return 'admin_onboarding_next_plugins';
case WizardSteps.InviteMembers:
return 'admin_onboarding_next_invite_members';
case WizardSteps.LaunchingWorkspace:
return 'admin_onboarding_next_transitioning_out';
default:
@ -31,8 +37,12 @@ export function mapStepToNextName(step: WizardStep): string {
export function mapStepToPrevious(step: WizardStep): string {
switch (step) {
case WizardSteps.Organization:
return 'admin_onboarding_previous_organization';
case WizardSteps.Plugins:
return 'admin_onboarding_previous_plugins';
case WizardSteps.InviteMembers:
return 'admin_onboarding_previous_invite_members';
case WizardSteps.LaunchingWorkspace:
return 'admin_onboarding_previous_transitioning_out';
default:
@ -42,8 +52,12 @@ export function mapStepToPrevious(step: WizardStep): string {
export function mapStepToPageView(step: WizardStep): string {
switch (step) {
case WizardSteps.Organization:
return 'pageview_admin_onboarding_organization';
case WizardSteps.Plugins:
return 'pageview_admin_onboarding_plugins';
case WizardSteps.InviteMembers:
return 'pageview_admin_onboarding_invite_members';
case WizardSteps.LaunchingWorkspace:
return 'pageview_admin_onboarding_transitioning_out';
default:
@ -53,8 +67,12 @@ export function mapStepToPageView(step: WizardStep): string {
export function mapStepToSubmitFail(step: WizardStep): string {
switch (step) {
case WizardSteps.Organization:
return 'admin_onboarding_organization_submit_fail';
case WizardSteps.Plugins:
return 'admin_onboarding_plugins_submit_fail';
case WizardSteps.InviteMembers:
return 'admin_onboarding_invite_members_submit_fail';
case WizardSteps.LaunchingWorkspace:
return 'admin_onboarding_transitioning_out_submit_fail';
default:
@ -64,8 +82,12 @@ export function mapStepToSubmitFail(step: WizardStep): string {
export function mapStepToSkipName(step: WizardStep): string {
switch (step) {
case WizardSteps.Organization:
return 'admin_onboarding_skip_organization';
case WizardSteps.Plugins:
return 'admin_onboarding_skip_plugins';
case WizardSteps.InviteMembers:
return 'admin_onboarding_skip_invite_members';
case WizardSteps.LaunchingWorkspace:
return 'admin_onboarding_skip_transitioning_out';
default:
@ -128,12 +150,14 @@ export type Form = {
skipped: boolean;
};
teamMembers: {
inviteId: string;
invites: string[];
skipped: boolean;
};
}
export const emptyForm = deepFreeze({
organization: '',
inferredProtocol: null,
urlSkipped: false,
useCase: {
@ -156,6 +180,7 @@ export const emptyForm = deepFreeze({
skipped: false,
},
teamMembers: {
inviteId: '',
invites: [],
skipped: false,
},
@ -165,7 +190,7 @@ export type PreparingWorkspacePageProps = {
transitionDirection: AnimationReason;
next?: () => void;
skip?: () => void;
previous?: JSX.Element;
previous?: React.ReactNode;
show: boolean;
onPageView: () => void;
}

View File

@ -10,7 +10,7 @@ import classNames from 'classnames';
import {Client4} from 'mattermost-redux/client';
import {rudderAnalytics, RudderTelemetryHandler} from 'mattermost-redux/client/rudder';
import {General} from 'mattermost-redux/constants';
import {Theme, getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences';
import {Theme} from 'mattermost-redux/selectors/entities/preferences';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUser, isCurrentUserSystemAdmin, checkIsFirstAdmin} from 'mattermost-redux/selectors/entities/users';
import {setUrl} from 'mattermost-redux/actions/general';
@ -89,6 +89,8 @@ import {ActionResult} from 'mattermost-redux/types/actions';
import WelcomePostRenderer from 'components/welcome_post_renderer';
import {getMyTeams} from 'mattermost-redux/selectors/entities/teams';
import {applyLuxonDefaults} from './effects';
import RootProvider from './root_provider';
@ -358,8 +360,8 @@ export default class Root extends React.PureComponent<Props, State> {
return;
}
const useCaseOnboarding = getUseCaseOnboarding(storeState);
if (!useCaseOnboarding) {
const myTeams = getMyTeams(storeState);
if (myTeams.length > 0) {
GlobalActions.redirectUserToDefaultTeam();
return;
}

View File

@ -6,7 +6,6 @@ import {connect} from 'react-redux';
import {getFirstAdminSetupComplete} from 'mattermost-redux/actions/general';
import {getCurrentUserId, isCurrentUserSystemAdmin, isFirstAdmin} from 'mattermost-redux/selectors/entities/users';
import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences';
import {GenericAction} from 'mattermost-redux/types/actions';
import {GlobalState} from 'types/store';
@ -14,11 +13,7 @@ import {GlobalState} from 'types/store';
import RootRedirect, {Props} from './root_redirect';
function mapStateToProps(state: GlobalState) {
const useCaseOnboarding = getUseCaseOnboarding(state);
let isElegibleForFirstAdmingOnboarding = useCaseOnboarding;
if (isElegibleForFirstAdmingOnboarding) {
isElegibleForFirstAdmingOnboarding = isCurrentUserSystemAdmin(state);
}
const isElegibleForFirstAdmingOnboarding = isCurrentUserSystemAdmin(state);
return {
currentUserId: getCurrentUserId(state),
isElegibleForFirstAdmingOnboarding,

View File

@ -7,8 +7,6 @@ import {IntlProvider} from 'react-intl';
import {BrowserRouter} from 'react-router-dom';
import {act, screen} from '@testing-library/react';
import * as global_actions from 'actions/global_actions';
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
import Signup from 'components/signup/signup';
@ -197,9 +195,6 @@ describe('components/signup/Signup', () => {
mockResolvedValueOnce({data: {id: 'userId', password: 'password', email: 'jdoe@mm.com}'}}). // createUser
mockResolvedValueOnce({error: {server_error_id: 'api.user.login.not_verified.app_error'}}); // loginById
const mockRedirectUserToDefaultTeam = jest.fn();
jest.spyOn(global_actions, 'redirectUserToDefaultTeam').mockImplementation(mockRedirectUserToDefaultTeam);
const wrapper = mountWithIntl(
<IntlProvider {...intlProviderProps}>
<BrowserRouter>
@ -228,7 +223,6 @@ describe('components/signup/Signup', () => {
expect(wrapper.find('#input_name').first().props().disabled).toEqual(true);
expect(wrapper.find(PasswordInput).first().props().disabled).toEqual(true);
expect(mockRedirectUserToDefaultTeam).not.toHaveBeenCalled();
expect(mockHistoryPush).toHaveBeenCalledWith('/should_verify_email?email=jdoe%40mm.com&teamname=teamName');
});
@ -238,9 +232,6 @@ describe('components/signup/Signup', () => {
mockResolvedValueOnce({data: {id: 'userId', password: 'password', email: 'jdoe@mm.com}'}}). // createUser
mockResolvedValueOnce({}); // loginById
const mockRedirectUserToDefaultTeam = jest.fn();
jest.spyOn(global_actions, 'redirectUserToDefaultTeam').mockImplementation(mockRedirectUserToDefaultTeam);
const wrapper = mountWithIntl(
<IntlProvider {...intlProviderProps}>
<BrowserRouter>
@ -268,8 +259,6 @@ describe('components/signup/Signup', () => {
expect(wrapper.find(Input).first().props().disabled).toEqual(true);
expect(wrapper.find('#input_name').first().props().disabled).toEqual(true);
expect(wrapper.find(PasswordInput).first().props().disabled).toEqual(true);
expect(mockRedirectUserToDefaultTeam).toHaveBeenCalled();
});
it('should add user to team and redirect when team invite valid and logged in', async () => {

View File

@ -17,7 +17,7 @@ import {getTeamInviteInfo} from 'mattermost-redux/actions/teams';
import {createUser, loadMe, loadMeREST} from 'mattermost-redux/actions/users';
import {DispatchFunc} from 'mattermost-redux/types/actions';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {getUseCaseOnboarding, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {isEmail} from 'mattermost-redux/utils/helpers';
@ -25,7 +25,6 @@ import {GlobalState} from 'types/store';
import {getGlobalItem} from 'selectors/storage';
import {redirectUserToDefaultTeam} from 'actions/global_actions';
import {removeGlobalItem, setGlobalItem} from 'actions/storage';
import {addUserToTeamFromInvite} from 'actions/team_actions';
import {trackEvent} from 'actions/telemetry_actions.jsx';
@ -104,7 +103,6 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
} = config;
const {IsLicensed, Cloud} = useSelector(getLicense);
const loggedIn = Boolean(useSelector(getCurrentUserId));
const useCaseOnboarding = useSelector(getUseCaseOnboarding);
const usedBefore = useSelector((state: GlobalState) => (!inviteId && !loggedIn && token ? getGlobalItem(state, token, null) : undefined));
const graphQLEnabled = useSelector(isGraphQLEnabled);
@ -310,15 +308,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
} else if (inviteId) {
getInviteInfo(inviteId);
} else if (loggedIn) {
if (useCaseOnboarding) {
// need info about whether admin or not,
// and whether admin has already completed
// first tiem onboarding. Instead of fetching and orchestrating that here,
// let the default root component handle it.
history.push('/');
} else {
redirectUserToDefaultTeam();
}
history.push('/');
}
}
@ -461,14 +451,12 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
if (redirectTo) {
history.push(redirectTo);
} else if (useCaseOnboarding) {
} else {
// need info about whether admin or not,
// and whether admin has already completed
// first tiem onboarding. Instead of fetching and orchestrating that here,
// let the default root component handle it.
history.push('/');
} else {
redirectUserToDefaultTeam();
}
};

View File

@ -6,7 +6,6 @@ import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux';
import {getTermsOfService, updateMyTermsOfServiceStatus} from 'mattermost-redux/actions/users';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences';
import {GlobalState} from '@mattermost/types/store';
import {ActionFunc, GenericAction} from 'mattermost-redux/types/actions';
@ -26,9 +25,7 @@ type Actions = {
function mapStateToProps(state: GlobalState) {
const config = getConfig(state);
const useCaseOnboarding = getUseCaseOnboarding(state);
return {
useCaseOnboarding,
termsEnabled: config.EnableCustomTermsOfService === 'true',
emojiMap: getEmojiMap(state),
};

View File

@ -27,7 +27,6 @@ describe('components/terms_of_service/TermsOfService', () => {
location: {search: ''},
termsEnabled: true,
emojiMap: {} as EmojiMap,
useCaseOnboarding: false,
};
test('should match snapshot', () => {

View File

@ -38,7 +38,6 @@ export interface TermsOfServiceProps {
) => {data: UpdateMyTermsOfServiceStatusResponse};
};
emojiMap: EmojiMap;
useCaseOnboarding: boolean;
}
interface TermsOfServiceState {
@ -111,14 +110,12 @@ export default class TermsOfService extends React.PureComponent<TermsOfServicePr
const redirectTo = query.get('redirect_to');
if (redirectTo && redirectTo.match(/^\/([^/]|$)/)) {
getHistory().push(redirectTo);
} else if (this.props.useCaseOnboarding) {
} else {
// need info about whether admin or not,
// and whether admin has already completed
// first time onboarding. Instead of fetching and orchestrating that here,
// let the default root component handle it.
getHistory().push('/');
} else {
GlobalActions.redirectUserToDefaultTeam();
}
},
);

View File

@ -4318,10 +4318,26 @@
"notify_here.question": "By using **@here** you are about to send notifications to up to **{totalMembers} other people**. Are you sure you want to do this?",
"notify_here.question_timezone": "By using **@here** you are about to send notifications to up to **{totalMembers} other people** in **{timezones, number} {timezones, plural, one {timezone} other {timezones}}**. Are you sure you want to do this?",
"numMembers": "{num, number} {num, plural, one {member} other {members}}",
"onboarding_wizard.cloud_plugins.description": "Mattermost is better when integrated with the tools your team uses for collaboration. Popular tools are below, select the ones your team uses and we'll add them to your workspace. Additional set up may be needed later.",
"onboarding_wizard.cloud_plugins.subtitle": "(almost there!)",
"onboarding_wizard.cloud_plugins.title": "Welcome to Mattermost!",
"onboarding_wizard.invite_members.copied_link": "Link Copied",
"onboarding_wizard.invite_members.copy_link": "Copy Link",
"onboarding_wizard.invite_members.copy_link_input": "team invite link",
"onboarding_wizard.invite_members.description_link": "Collaboration is tough by yourself. Invite a few team members using the invitation link below.",
"onboarding_wizard.invite_members.next_link": "Finish setup",
"onboarding_wizard.invite_members.title": "Invite your team members",
"onboarding_wizard.launching_workspace.description": "Itll be ready in a moment",
"onboarding_wizard.launching_workspace.title": "Launching your workspace now",
"onboarding_wizard.next": "Continue",
"onboarding_wizard.plugins.description": "Mattermost is better when integrated with the tools your team uses for collaboration. Popular tools are below, select the ones your team uses and we'll add them to your workspace. Additional set up may be needed later.",
"onboarding_wizard.organization.description": "Well use this to help personalize your workspace.",
"onboarding_wizard.organization.empty": "You must enter an organization name",
"onboarding_wizard.organization.length": "Organization name must be between {min} and {max} characters",
"onboarding_wizard.organization.other": "Invalid organization name: {reason}",
"onboarding_wizard.organization.placeholder": "Organization name",
"onboarding_wizard.organization.reserved": "Organization name may not <a>start with a reserved word</a>.",
"onboarding_wizard.organization.team_api_error": "There was an error, please try again.",
"onboarding_wizard.organization.title": "Whats the name of your organization?",
"onboarding_wizard.plugins.github": "GitHub",
"onboarding_wizard.plugins.github.tooltip": "Subscribe to repositories, stay up to date with reviews, assignments",
"onboarding_wizard.plugins.gitlab": "GitLab",
@ -4329,13 +4345,14 @@
"onboarding_wizard.plugins.jira": "Jira",
"onboarding_wizard.plugins.jira.tooltip": "Create Jira tickets from messages in Mattermost, get notified of important updates in Jira",
"onboarding_wizard.plugins.marketplace": "More tools can be added once your workspace is set up. To see all available integrations, <a>visit the Marketplace.</a>",
"onboarding_wizard.plugins.subtitle": "(almost there!)",
"onboarding_wizard.plugins.title": "Welcome to Mattermost!",
"onboarding_wizard.plugins.todo": "To do",
"onboarding_wizard.plugins.todo.tooltip": "A plugin to track Todo issues in a list and send you daily reminders about your Todo list",
"onboarding_wizard.plugins.zoom": "Zoom",
"onboarding_wizard.plugins.zoom.tooltip": "Start Zoom audio and video conferencing calls in Mattermost with a single click",
"onboarding_wizard.skip": "Skip for now",
"onboarding_wizard.previous": "Previous",
"onboarding_wizard.self_hosted_plugins.description": "Choose the tools you work with, and we'll add them to your workspace. Additional set up may be needed later.",
"onboarding_wizard.self_hosted_plugins.title": "What tools do you use?",
"onboarding_wizard.skip-button": "Skip",
"onboarding_wizard.submit_error.generic": "Something went wrong. Please try again.",
"onboardingTask.checklist.completed_subtitle": "We hope Mattermost is more familiar now.",
"onboardingTask.checklist.completed_title": "Well done. Youve completed all of the tasks!",

View File

@ -245,10 +245,6 @@ export function isCustomGroupsEnabled(state: GlobalState): boolean {
return getConfig(state).EnableCustomGroups === 'true';
}
export function getUseCaseOnboarding(state: GlobalState): boolean {
return getFeatureFlagValue(state, 'UseCaseOnboarding') === 'true' && getLicense(state)?.Cloud === 'true';
}
export function insightsAreEnabled(state: GlobalState): boolean {
const isConfiguredForFeature = getConfig(state).InsightsEnabled === 'true';
const featureIsEnabled = getFeatureFlagValue(state, 'InsightsEnabled') === 'true';

View File

@ -1099,6 +1099,7 @@ export const DocLinks = {
ONBOARD_LDAP: 'https://docs.mattermost.com/onboard/ad-ldap.html',
ONBOARD_SSO: 'https://docs.mattermost.com/onboard/sso-saml.html',
TRUE_UP_REVIEW: 'https://mattermost.com/pl/true-up-documentation',
ABOUT_TEAMS: 'https://docs.mattermost.com/welcome/about-teams.html#team-url',
};
export const LicenseLinks = {

View File

@ -2,5 +2,6 @@
// See LICENSE.txt for license information.
export type CompleteOnboardingRequest = {
organization: string;
install_plugins: string[];
}