-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Bulk invite team members (setup section II) (#3143)
Co-authored-by: Michael Matloka <[email protected]>
- Loading branch information
1 parent
6efdb8e
commit de317c8
Showing
22 changed files
with
588 additions
and
83 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}") | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
12 changes: 12 additions & 0 deletions
12
frontend/src/scenes/organization/TeamMembers/BulkInviteModal.scss
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
109
frontend/src/scenes/organization/TeamMembers/BulkInviteModal.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
79 changes: 79 additions & 0 deletions
79
frontend/src/scenes/organization/TeamMembers/bulkInviteLogic.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.`) | ||
}, | ||
}), | ||
}) |
Oops, something went wrong.