Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 58 additions & 27 deletions jsapp/js/account/organization/InviteModal.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,52 @@
import { useState } from 'react'

import type { ModalProps } from '@mantine/core'
import { Group, Loader, Modal, Stack, Text, TextInput } from '@mantine/core'
import { useField } from '@mantine/form'
import { useState } from 'react'
import { getSimpleMMOLabel } from '#/account/organization/organization.utils'
import subscriptionStore from '#/account/subscriptionStore'
import { MemberRoleEnum } from '#/api/models/memberRoleEnum'
import { InviteeRoleEnum } from '#/api/models/inviteeRoleEnum'
import {
getOrganizationsInvitesListQueryKey,
getOrganizationsInvitesRetrieveQueryKey,
useOrganizationsInvitesCreate,
} from '#/api/react-query/organization-invites'
import {
getOrganizationsMembersListQueryKey,
getOrganizationsMembersRetrieveQueryKey,
} from '#/api/react-query/organization-members'
import { useOrganizationAssumed } from '#/api/useOrganizationAssumed'
import ButtonNew from '#/components/common/ButtonNew'
import Select from '#/components/common/Select'
import type { FailResponse } from '#/dataInterface'
import envStore from '#/envStore'
import { queryClient } from '#/query/queryClient'
import userExistence from '#/users/userExistence.store'
import { checkEmailPattern, notify } from '#/utils'
import { useSendMemberInvite } from './membersInviteQuery'
import { inviteGuidFromUrl } from './common'

export default function InviteModal(props: ModalProps) {
const inviteQuery = useSendMemberInvite()
const inviteQuery = useOrganizationsInvitesCreate({
mutation: {
onSuccess: (data, variables) => {
if (data.status !== 201) return // typeguard, `onSuccess` will always be 201.
queryClient.invalidateQueries({ queryKey: getOrganizationsInvitesListQueryKey(variables.organizationId) })
for (const invite of data.data) {
queryClient.invalidateQueries({
queryKey: getOrganizationsInvitesRetrieveQueryKey(variables.organizationId, inviteGuidFromUrl(invite.url)),
})
}
queryClient.invalidateQueries({ queryKey: getOrganizationsMembersListQueryKey(variables.organizationId) })
// Note: invalidate ALL members because username isn't available in scope to select the exact member.
queryClient.invalidateQueries({
queryKey: getOrganizationsMembersRetrieveQueryKey(variables.organizationId, 'unknown').slice(0, -1),
})
},
},
})
const [organization] = useOrganizationAssumed()
const mmoLabel = getSimpleMMOLabel(envStore.data, subscriptionStore.activeSubscriptions[0])

const [role, setRole] = useState<MemberRoleEnum | null>(null)
const [role, setRole] = useState<InviteeRoleEnum | null>(null)

async function handleUsernameOrEmailCheck(value: string) {
if (value === '' || checkEmailPattern(value)) {
Expand All @@ -38,26 +67,28 @@ export default function InviteModal(props: ModalProps) {
validateOnBlur: true,
})

const handleSendInvite = () => {
const handleSendInvite = async () => {
if (!role) return

inviteQuery
.mutateAsync({
invitees: [userOrEmail.getValue()],
role: role,
})
.then(() => {
userOrEmail.reset()
setRole(null)
props.onClose()
})
.catch((error) => {
if (error.responseText && JSON.parse(error.responseText)?.invitees) {
notify(JSON.parse(error.responseText)?.invitees.join(), 'error')
} else {
notify(t('Failed to send invite'), 'error')
}
try {
await inviteQuery.mutateAsync({
organizationId: organization.id,
data: {
invitees: [userOrEmail.getValue()],
role: role,
},
})
userOrEmail.reset()
setRole(null)
props.onClose()
} catch (error) {
const responseText = (error as FailResponse).responseText
if (responseText && JSON.parse(responseText)?.invitees) {
notify(JSON.parse(responseText)?.invitees.join(), 'error')
} else {
console.error(error)
notify(t('Failed to send invite'), 'error')
}
}
}

const handleClose = () => {
Expand Down Expand Up @@ -89,17 +120,17 @@ export default function InviteModal(props: ModalProps) {
placeholder='Role'
data={[
{
value: MemberRoleEnum.admin,
value: InviteeRoleEnum.admin,
label: t('Admin'),
},
{
value: MemberRoleEnum.member,
value: InviteeRoleEnum.member,
label: t('Member'),
},
]}
value={role}
// TODO: parameterize <Select/> to infer values from data property.
onChange={(value) => setRole(value as MemberRoleEnum)}
onChange={(value) => setRole(value as InviteeRoleEnum)}
/>
</Group>
<Group w='100%' justify='flex-end'>
Expand Down
73 changes: 63 additions & 10 deletions jsapp/js/account/organization/InviteeActionsDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@ import type { ReactNode } from 'react'

import { Group, LoadingOverlay, Menu, Modal, Stack, Text } from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import type { InviteResponse } from '#/api/models/inviteResponse'
import { InviteStatusChoicesEnum } from '#/api/models/inviteStatusChoicesEnum'
import {
getOrganizationsInvitesListQueryKey,
getOrganizationsInvitesRetrieveQueryKey,
useOrganizationsInvitesDestroy,
useOrganizationsInvitesPartialUpdate,
} from '#/api/react-query/organization-invites'
import {
getOrganizationsMembersListQueryKey,
getOrganizationsMembersRetrieveQueryKey,
} from '#/api/react-query/organization-members'
import { useOrganizationAssumed } from '#/api/useOrganizationAssumed'
import ButtonNew from '#/components/common/ButtonNew'
import { queryClient } from '#/query/queryClient'
import { notify } from '#/utils'
import type { MemberInvite } from './membersInviteQuery'
import { MemberInviteStatus, usePatchMemberInvite, useRemoveMemberInvite } from './membersInviteQuery'
import { inviteGuidFromUrl } from './common'

/**
* A dropdown with all actions that can be taken towards an organization invitee.
Expand All @@ -15,17 +28,54 @@ export default function InviteeActionsDropdown({
invite,
}: {
target: ReactNode
invite: MemberInvite
invite: InviteResponse
}) {
const [organization] = useOrganizationAssumed()

const [opened, { open, close }] = useDisclosure()

const patchInviteMutation = usePatchMemberInvite(invite.url)
const removeInviteMutation = useRemoveMemberInvite()
const orgInvitesPatchMutation = useOrganizationsInvitesPartialUpdate({
mutation: {
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: getOrganizationsInvitesListQueryKey(variables.organizationId) })
queryClient.invalidateQueries({
queryKey: getOrganizationsInvitesRetrieveQueryKey(variables.organizationId, variables.guid),
})
queryClient.invalidateQueries({ queryKey: getOrganizationsMembersListQueryKey(variables.organizationId) })
// Note: invalidate ALL members because username isn't available in scope to select the exact member.
queryClient.invalidateQueries({
queryKey: getOrganizationsMembersRetrieveQueryKey(variables.organizationId, 'unknown').slice(0, -1),
})
},
},
request: {
errorMessageDisplay: t('There was an error updating this invitation.'),
},
})
const orgInvitesDestroyMutation = useOrganizationsInvitesDestroy({
mutation: {
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: getOrganizationsInvitesListQueryKey(variables.organizationId) })
queryClient.invalidateQueries({
queryKey: getOrganizationsInvitesRetrieveQueryKey(variables.organizationId, variables.guid),
})
queryClient.invalidateQueries({ queryKey: getOrganizationsMembersListQueryKey(variables.organizationId) })
// Note: invalidate ALL members because username isn't available in scope to select the exact member.
queryClient.invalidateQueries({
queryKey: getOrganizationsMembersRetrieveQueryKey(variables.organizationId, 'unknown').slice(0, -1),
})
},
},
})

const resendInvitation = async () => {
try {
await patchInviteMutation.mutateAsync({
status: MemberInviteStatus.resent,
await orgInvitesPatchMutation.mutateAsync({
organizationId: organization.id,
guid: inviteGuidFromUrl(invite.url),
data: {
status: InviteStatusChoicesEnum.resent,
},
})
notify(t('The invitation was resent'), 'success')
} catch (e: any) {
Expand All @@ -50,7 +100,10 @@ export default function InviteeActionsDropdown({

const removeInvitation = async () => {
try {
await removeInviteMutation.mutateAsync(invite.url)
await orgInvitesDestroyMutation.mutateAsync({
organizationId: organization.id,
guid: inviteGuidFromUrl(invite.url),
})
notify(t('Invitation removed'), 'success')
} catch (e) {
notify(t('An error occurred while removing the invitation'), 'error')
Expand All @@ -62,7 +115,7 @@ export default function InviteeActionsDropdown({
return (
<>
<Modal opened={opened} onClose={close} title={t('Remove invitation?')}>
<LoadingOverlay visible={removeInviteMutation.isPending} />
<LoadingOverlay visible={orgInvitesDestroyMutation.isPending} />
<Stack>
<Text>{t("Are you sure you want to remove this user's invitation to join the team?")}</Text>
<Group justify='flex-end'>
Expand All @@ -76,7 +129,7 @@ export default function InviteeActionsDropdown({
</Stack>
</Modal>

<LoadingOverlay visible={patchInviteMutation.isPending} />
<LoadingOverlay visible={orgInvitesPatchMutation.isPending} />
<Menu offset={0} position='bottom-end'>
<Menu.Target>{target}</Menu.Target>

Expand Down
45 changes: 35 additions & 10 deletions jsapp/js/account/organization/MemberRemoveModal.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import subscriptionStore from '#/account/subscriptionStore'
import {
getOrganizationsMembersListQueryKey,
getOrganizationsMembersRetrieveQueryKey,
useOrganizationsMembersDestroy,
} from '#/api/react-query/organization-members'
import { useOrganizationAssumed } from '#/api/useOrganizationAssumed'
import Button from '#/components/common/button'
import InlineMessage from '#/components/common/inlineMessage'
import KoboModal from '#/components/modals/koboModal'
import KoboModalContent from '#/components/modals/koboModalContent'
import KoboModalFooter from '#/components/modals/koboModalFooter'
import KoboModalHeader from '#/components/modals/koboModalHeader'
import envStore from '#/envStore'
import { queryClient } from '#/query/queryClient'
import { useSession } from '#/stores/useSession'
import { notify } from '#/utils'
import { useRemoveOrganizationMember } from './membersQuery'
import { getSimpleMMOLabel } from './organization.utils'

interface MemberRemoveModalProps {
Expand All @@ -30,7 +37,28 @@ export default function MemberRemoveModal({
onConfirmDone,
onCancel,
}: MemberRemoveModalProps) {
const removeMember = useRemoveOrganizationMember()
const session = useSession()
const [organization] = useOrganizationAssumed()

const memberDestroy = useOrganizationsMembersDestroy({
mutation: {
onSuccess: (_data, { organizationId, userUsername }) => {
queryClient.invalidateQueries({ queryKey: getOrganizationsMembersListQueryKey(organizationId) })
queryClient.invalidateQueries({
queryKey: getOrganizationsMembersRetrieveQueryKey(organizationId, userUsername),
})
// If user is removing themselves, we need to clear the session
if (userUsername === session.currentLoggedAccount?.username) {
session.refreshAccount()
return
}
},
onError: () => {
notify('Failed to remove member', 'error')
},
onSettled: () => onConfirmDone(),
},
})
const mmoLabel = getSimpleMMOLabel(envStore.data, subscriptionStore.activeSubscriptions[0], false, false)

// There are two different sets of strings - one for removing a member, and
Expand Down Expand Up @@ -61,13 +89,10 @@ export default function MemberRemoveModal({
}

const handleRemoveMember = async () => {
try {
await removeMember.mutateAsync(username)
} catch (error) {
notify('Failed to remove member', 'error')
} finally {
onConfirmDone()
}
await memberDestroy.mutateAsync({
organizationId: organization.id,
userUsername: username,
})
}

return (
Expand All @@ -88,7 +113,7 @@ export default function MemberRemoveModal({
size='m'
onClick={handleRemoveMember}
label={textToDisplay.confirmButtonLabel}
isPending={removeMember.isPending}
isPending={memberDestroy.isPending}
/>
</KoboModalFooter>
</KoboModal>
Expand Down
Loading
Loading