Skip to content

Commit

Permalink
Bulk invite team members (setup section II) (#3143)
Browse files Browse the repository at this point in the history
Co-authored-by: Michael Matloka <[email protected]>
  • Loading branch information
paolodamico and Twixes authored Feb 3, 2021
1 parent 6efdb8e commit de317c8
Show file tree
Hide file tree
Showing 22 changed files with 588 additions and 83 deletions.
2 changes: 0 additions & 2 deletions ee/api/test/test_organization.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from typing import cast
from unittest.mock import patch

from django.test.utils import tag
from rest_framework import status

from ee.api.test.base import APILicensedTest
Expand Down
37 changes: 35 additions & 2 deletions ee/api/test/test_team.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,51 @@
from rest_framework import status

from ee.api.test.base import APILicensedTest
from posthog.models.organization import Organization, OrganizationMembership
from posthog.models.team import Team
from posthog.models.user import User


class TestTeamEnterpriseAPI(APILicensedTest):
def test_create_team(self):
class TestProjectEnterpriseAPI(APILicensedTest):

# Creating Projects
def test_create_project(self):
response = self.client.post("/api/projects/", {"name": "Test"})
self.assertEqual(response.status_code, 201)
self.assertEqual(Team.objects.count(), 2)
response_data = response.json()
self.assertEqual(response_data.get("name"), "Test")
self.assertEqual(self.organization.teams.count(), 2)

def test_non_admin_cannot_create_project(self):
self.organization_membership.level = OrganizationMembership.Level.MEMBER
self.organization_membership.save()
count = Team.objects.count()
response = self.client.post("/api/projects/", {"name": "Test"})
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(Team.objects.count(), count)
self.assertEqual(
response.json(), self.permission_denied_response("Your organization access level is insufficient.")
)

def test_user_that_does_not_belong_to_an_org_cannot_create_a_project(self):
user = User.objects.create(email="[email protected]")
self.client.force_login(user)

response = self.client.post("/api/projects/", {"name": "Test"})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(
response.json(),
{
"type": "validation_error",
"code": "invalid_input",
"detail": "You need to belong to an organization.",
"attr": None,
},
)

# Deleting projects

def test_delete_team_own_second(self):
team = Team.objects.create(organization=self.organization)
response = self.client.delete(f"/api/projects/{team.id}")
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/global.scss
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,12 @@ code.code {
}
}

.error-on-blur {
&.errored:not(:focus) {
border-color: $danger !important;
}
}

// Button styles
.btn-close {
color: $text_muted;
Expand All @@ -322,6 +328,11 @@ code.code {
margin-left: 5px;
}

.btn-add {
color: $text_muted !important;
border: 1px dashed $border !important;
}

// Badges styles
.badge {
border-radius: 50%;
Expand Down
19 changes: 13 additions & 6 deletions frontend/src/scenes/onboarding/OnboardingSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { CreateProjectModal } from 'scenes/project/CreateProjectModal'
import { Link } from 'lib/components/Link'
import { IconExternalLink } from 'lib/components/icons'
import { userLogic } from 'scenes/userLogic'
import { BulkInviteModal } from 'scenes/organization/TeamMembers/BulkInviteModal'

const { Panel } = Collapse

Expand Down Expand Up @@ -98,14 +99,19 @@ function OnboardingStep({
export const OnboardingSetup = hot(_OnboardingSetup)
function _OnboardingSetup(): JSX.Element {
const [slackClicked, setslackClicked] = useState(false)
const { stepProjectSetup, stepInstallation, projectModalShown, stepVerification, currentSection } = useValues(
onboardingSetupLogic
)
const {
stepProjectSetup,
stepInstallation,
projectModalShown,
stepVerification,
currentSection,
inviteTeamModalShown,
} = useValues(onboardingSetupLogic)
const { switchToNonDemoProject, setProjectModalShown, setInviteTeamModalShown } = useActions(onboardingSetupLogic)

const { user, userUpdateLoading } = useValues(userLogic)
const { userUpdateRequest } = useActions(userLogic)

const { switchToNonDemoProject, setProjectModalShown } = useActions(onboardingSetupLogic)

const UTM_TAGS = 'utm_medium=in-product&utm_campaign=onboarding-setup-2822'

return (
Expand Down Expand Up @@ -225,7 +231,7 @@ function _OnboardingSetup(): JSX.Element {
title="Invite your team members"
icon={<UsergroupAddOutlined />}
identifier="invite-team"
handleClick={() => setProjectModalShown(true)}
handleClick={() => setInviteTeamModalShown(true)}
caption="Spread the knowledge, share insights with everyone in your team."
customActionElement={
<Button type="primary" icon={<PlusOutlined />}>
Expand Down Expand Up @@ -262,6 +268,7 @@ function _OnboardingSetup(): JSX.Element {
</div>
}
/>
<BulkInviteModal visible={inviteTeamModalShown} onClose={() => setInviteTeamModalShown(false)} />
</>
) : (
<div className="already-completed">
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/scenes/onboarding/onboardingSetupLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const onboardingSetupLogic = kea<onboardingSetupLogicType>({
actions: {
switchToNonDemoProject: (dest) => ({ dest }),
setProjectModalShown: (shown) => ({ shown }),
setInviteTeamModalShown: (shown) => ({ shown }),
},
reducers: {
projectModalShown: [
Expand All @@ -18,6 +19,12 @@ export const onboardingSetupLogic = kea<onboardingSetupLogicType>({
setProjectModalShown: (_, { shown }) => shown,
},
],
inviteTeamModalShown: [
false,
{
setInviteTeamModalShown: (_, { shown }) => shown,
},
],
},
listeners: {
switchToNonDemoProject: ({ dest }: { dest: string }) => {
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/scenes/organization/TeamMembers/BulkInviteModal.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@import '~/vars';

.bulk-invite-modal {
.invite-row {
margin-top: $default_spacing;

&:first-of-type {
margin-top: 0;
background-color: red;
}
}
}
109 changes: 109 additions & 0 deletions frontend/src/scenes/organization/TeamMembers/BulkInviteModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Button, Col, Input, Row } from 'antd'
import Modal from 'antd/lib/modal/Modal'
import { useActions, useValues } from 'kea'
import React, { useEffect } from 'react'
import { userLogic } from 'scenes/userLogic'
import { PlusOutlined } from '@ant-design/icons'
import './BulkInviteModal.scss'
import { isEmail } from 'lib/utils'
import { bulkInviteLogic } from './bulkInviteLogic'

const PLACEHOLDER_NAMES = ['Jane', 'John']
const MAX_INVITES = 20

function InviteRow({ index }: { index: number }): JSX.Element {
const name = PLACEHOLDER_NAMES[index % 2]

const { invites } = useValues(bulkInviteLogic)
const { updateInviteAtIndex } = useActions(bulkInviteLogic)

return (
<Row gutter={16} className="invite-row">
<Col xs={12}>
<Input
placeholder={`${name.toLowerCase()}@posthog.com`}
type="email"
className={`error-on-blur${!invites[index].isValid ? ' errored' : ''}`}
onChange={(e) => {
const { value } = e.target
let isValid = true
if (value && !isEmail(value)) {
isValid = false
}
updateInviteAtIndex({ email: e.target.value, isValid }, index)
}}
value={invites[index].email}
/>
</Col>
<Col xs={12}>
<Input
placeholder={name}
onChange={(e) => {
updateInviteAtIndex({ first_name: e.target.value }, index)
}}
/>
</Col>
</Row>
)
}

export function BulkInviteModal({ visible, onClose }: { visible: boolean; onClose: () => void }): JSX.Element {
const { user } = useValues(userLogic)
const { invites, canSubmit, invitedTeamMembersLoading, invitedTeamMembers } = useValues(bulkInviteLogic)
const { addMoreInvites, resetInvites, inviteTeamMembers } = useActions(bulkInviteLogic)

useEffect(() => {
if (invitedTeamMembers.invites.length) {
onClose()
}
}, [invitedTeamMembers])

return (
<>
<Modal
title={`Invite your team members${user?.organization ? ' to ' + user?.organization?.name : ''}`}
visible={visible}
onCancel={() => {
resetInvites()
onClose()
}}
onOk={inviteTeamMembers}
okText="Invite team members"
destroyOnClose
okButtonProps={{ disabled: !canSubmit, loading: invitedTeamMembersLoading }}
cancelButtonProps={{ disabled: invitedTeamMembersLoading }}
closable={!invitedTeamMembersLoading}
>
<div className="bulk-invite-modal">
<div>
Invite as many team members as you want. <b>Names are optional</b>, but it will speed up the
process for your teammates.
</div>
<Row gutter={16} className="mt">
<Col xs={12}>
<b>Email (required)</b>
</Col>
<Col xs={12}>
<b>First Name</b>
</Col>
</Row>

{invites.map((_, index) => (
<InviteRow index={index} key={index.toString()} />
))}

<div className="mt">
<Button
block
className="btn-add"
onClick={addMoreInvites}
disabled={invites.length + 2 >= MAX_INVITES}
>
<PlusOutlined /> Add more team members
</Button>
</div>
</div>
</Modal>
</>
)
}
13 changes: 6 additions & 7 deletions frontend/src/scenes/organization/TeamMembers/Invites.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { invitesLogic } from './invitesLogic'
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
import { humanFriendlyDetailedTime } from 'lib/utils'
import { hot } from 'react-hot-loader/root'
import { OrganizationInviteType } from '~/types'
import { OrganizationInviteType, UserNestedType } from '~/types'
import { CopyToClipboardInline } from 'lib/components/CopyToClipboard'
import { CreateInviteModalWithButton } from './CreateInviteModal'

Expand Down Expand Up @@ -65,15 +65,15 @@ function _Invites(): JSX.Element {
{
title: 'Created At',
dataIndex: 'created_at',
key: 'created_by',
render: (createdAt: string) => humanFriendlyDetailedTime(createdAt),
key: 'created_at',
render: (created_at: string) => humanFriendlyDetailedTime(created_at),
},
{
title: 'Created By',
dataIndex: 'created_by_first_name',
dataIndex: 'created_by',
key: 'created_by',
render: (createdByFirstName: string, invite: Record<string, any>) =>
`${createdByFirstName} (${invite.created_by_email})`,
render: (created_by?: UserNestedType) =>
created_by ? `${created_by.first_name} (${created_by.email})` : '',
},
{
title: 'Invite Link',
Expand All @@ -85,7 +85,6 @@ function _Invites(): JSX.Element {
title: '',
dataIndex: 'actions',
key: 'actions',
align: 'center',
render: makeActionsComponent(deleteInvite),
},
]
Expand Down
79 changes: 79 additions & 0 deletions frontend/src/scenes/organization/TeamMembers/bulkInviteLogic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { kea } from 'kea'
import { bulkInviteLogicType } from './bulkInviteLogicType'
import { OrganizationInviteType } from '~/types'
import api from 'lib/api'
import { toast } from 'react-toastify'

interface InviteType {
email: string
first_name?: string
isValid: boolean
}

interface BulkInviteResponse {
invites: OrganizationInviteType[]
}

const DEFAULT_INVITE = { email: '', first_name: '', isValid: true }
const DEFAULT_INVITES = [DEFAULT_INVITE, DEFAULT_INVITE, DEFAULT_INVITE]

export const bulkInviteLogic = kea<bulkInviteLogicType<BulkInviteResponse, InviteType>>({
actions: {
updateInviteAtIndex: (payload, index: number) => ({ payload, index }),
addMoreInvites: true,
resetInvites: true,
},
reducers: {
invites: [
DEFAULT_INVITES as InviteType[],
{
updateInviteAtIndex: (state, { payload, index }) => {
const newState = [...state]
newState[index] = { ...state[index], ...payload }
return newState
},
addMoreInvites: (state) => {
return [...state, DEFAULT_INVITE, DEFAULT_INVITE]
},
resetInvites: () => DEFAULT_INVITES,
},
],
},
selectors: {
canSubmit: [
(selectors) => [selectors.invites],
(invites: InviteType[]) =>
invites.filter(({ email }) => !!email).length > 0 &&
invites.filter(({ isValid }) => !isValid).length == 0,
],
},
loaders: ({ values }) => ({
invitedTeamMembers: [
{ invites: [] } as BulkInviteResponse,
{
inviteTeamMembers: async () => {
if (!values.canSubmit) {
return { invites: [] }
}

const payload = {
invites: [] as { target_email: string | null; first_name?: string | null }[],
}

for (const invite of values.invites) {
if (!invite.email) {
continue
}
payload.invites.push({ target_email: invite.email, first_name: invite.first_name })
}
return await api.create('api/organizations/@current/invites/bulk/', payload)
},
},
],
}),
listeners: ({ values }) => ({
inviteTeamMembersSuccess: (): void => {
toast.success(`Invites sent to ${values.invitedTeamMembers.invites.length} new team members.`)
},
}),
})
Loading

0 comments on commit de317c8

Please sign in to comment.