Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
6 changes: 6 additions & 0 deletions CODING_STYLE_FE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,9 @@ Main principle is, keep related code close for modularity. Organize by feature/u
- use React functional components and hooks instead of classes and HOCs.
- for response caching use `react-query`, don't reinvent cache using state.

### Use Orval's react-query for API

- define invalidations and optionally optimistic updates at `#/api/mutation-defaults/…`
- when using callback options inline, be mindful to apply defaults with `getMutationDefaults`/`getQueryDefaults`.
- use inline the relavant API hook from `#/api/react-query/…`
- when transforming responses, prefer `select` option over transforming response too much.
57 changes: 31 additions & 26 deletions jsapp/js/account/organization/InviteModal.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
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 { useOrganizationsInvitesCreate } from '#/api/react-query/user-team-organization-usage'
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 userExistence from '#/users/userExistence.store'
import { checkEmailPattern, notify } from '#/utils'
import { useSendMemberInvite } from './membersInviteQuery'

export default function InviteModal(props: ModalProps) {
const inviteQuery = useSendMemberInvite()
const [organization] = useOrganizationAssumed()
const mmoLabel = getSimpleMMOLabel(envStore.data, subscriptionStore.activeSubscriptions[0])

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

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

async function handleUsernameOrEmailCheck(value: string) {
if (value === '' || checkEmailPattern(value)) {
Expand All @@ -38,26 +41,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 orgInviteCreate.mutateAsync({
uidOrganization: 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,11 +94,11 @@ 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'),
},
]}
Expand Down
40 changes: 29 additions & 11 deletions jsapp/js/account/organization/InviteeActionsDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ 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 {
useOrganizationsInvitesDestroy,
useOrganizationsInvitesPartialUpdate,
} from '#/api/react-query/user-team-organization-usage'
import { useOrganizationAssumed } from '#/api/useOrganizationAssumed'
import ButtonNew from '#/components/common/ButtonNew'
import { notify } from '#/utils'
import type { MemberInvite } from './membersInviteQuery'
import { MemberInviteStatus, usePatchMemberInvite, useRemoveMemberInvite } from './membersInviteQuery'
import { getAssetUIDFromUrl, notify } from '#/utils'

/**
* A dropdown with all actions that can be taken towards an organization invitee.
Expand All @@ -15,17 +20,27 @@ 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 orgInvitesPatch = useOrganizationsInvitesPartialUpdate({
request: {
errorMessageDisplay: t('There was an error updating this invitation.'),
},
})
const orgInvitesDestroy = useOrganizationsInvitesDestroy()

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

const removeInvitation = async () => {
try {
await removeInviteMutation.mutateAsync(invite.url)
await orgInvitesDestroy.mutateAsync({
uidOrganization: organization.id,
guid: getAssetUIDFromUrl(invite.url)!,
})
notify(t('Invitation removed'), 'success')
} catch (e) {
notify(t('An error occurred while removing the invitation'), 'error')
Expand All @@ -62,7 +80,7 @@ export default function InviteeActionsDropdown({
return (
<>
<Modal opened={opened} onClose={close} title={t('Remove invitation?')}>
<LoadingOverlay visible={removeInviteMutation.isPending} />
<LoadingOverlay visible={orgInvitesDestroy.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 +94,7 @@ export default function InviteeActionsDropdown({
</Stack>
</Modal>

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

Expand Down
37 changes: 26 additions & 11 deletions jsapp/js/account/organization/MemberRemoveModal.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import subscriptionStore from '#/account/subscriptionStore'
import { queryClient } from '#/api/queryClient'
import {
getOrganizationsMembersDestroyMutationOptions,
useOrganizationsMembersDestroy,
} from '#/api/react-query/user-team-organization-usage'
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 { notify } from '#/utils'
import { useRemoveOrganizationMember } from './membersQuery'
import { getSimpleMMOLabel } from './organization.utils'

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

const orgMemberDestroy = useOrganizationsMembersDestroy({
mutation: {
onSettled: async (data, error, variables, context) => {
await queryClient
.getMutationDefaults(getOrganizationsMembersDestroyMutationOptions().mutationKey!)
.onSettled?.(data, error, variables, context)
onConfirmDone()
},
},
request: {
errorMessageDisplay: 'Failed to remove member',
},
})
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 +79,10 @@ export default function MemberRemoveModal({
}

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

return (
Expand All @@ -88,7 +103,7 @@ export default function MemberRemoveModal({
size='m'
onClick={handleRemoveMember}
label={textToDisplay.confirmButtonLabel}
isPending={removeMember.isPending}
isPending={orgMemberDestroy.isPending}
/>
</KoboModalFooter>
</KoboModal>
Expand Down
41 changes: 28 additions & 13 deletions jsapp/js/account/organization/MemberRoleSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,60 @@
import { LoadingOverlay } from '@mantine/core'
import { MemberRoleEnum } from '#/api/models/memberRoleEnum'
import { InviteeRoleEnum } from '#/api/models/inviteeRoleEnum'
import type { MemberRoleEnum } from '#/api/models/memberRoleEnum'
import {
useOrganizationsInvitesPartialUpdate,
useOrganizationsMembersPartialUpdate,
} from '#/api/react-query/user-team-organization-usage'
import { useOrganizationAssumed } from '#/api/useOrganizationAssumed'
import Select from '#/components/common/Select'
import { usePatchMemberInvite } from './membersInviteQuery'
import { usePatchOrganizationMember } from './membersQuery'
import { getAssetUIDFromUrl } from '#/utils'

interface MemberRoleSelectorProps {
username: string
/** The role of the `username` user - the one we are modifying here. */
role: MemberRoleEnum
role: InviteeRoleEnum
/** The role of the currently logged in user. */
currentUserRole: MemberRoleEnum
/** URL for patching org member invites. Should only be passed if invite is still open */
inviteUrl?: string
}

export default function MemberRoleSelector({ username, role, inviteUrl }: MemberRoleSelectorProps) {
const patchMember = usePatchOrganizationMember(username)
const patchInvite = usePatchMemberInvite(inviteUrl)
const [organization] = useOrganizationAssumed()

const handleRoleChange = (newRole: MemberRoleEnum | null) => {
if (!newRole) return
const orgMembersPatch = useOrganizationsMembersPartialUpdate()
const orgInvitesPatch = useOrganizationsInvitesPartialUpdate({
request: {
errorMessageDisplay: t('There was an error updating this invitation.'),
},
})

const handleRoleChange = async (role: InviteeRoleEnum | null) => {
if (!role) return

if (inviteUrl) {
patchInvite.mutateAsync({ role })
await orgInvitesPatch.mutateAsync({
guid: getAssetUIDFromUrl(inviteUrl)!,
uidOrganization: organization.id,
data: { role },
})
} else {
patchMember.mutateAsync({ role })
await orgMembersPatch.mutateAsync({ uidOrganization: organization.id, username: username, data: { role } })
}
}

return (
<>
<LoadingOverlay visible={patchMember.isPending || patchInvite.isPending} />
<LoadingOverlay visible={orgMembersPatch.isPending || orgInvitesPatch.isPending} />
<Select
size='sm'
data={[
{
value: MemberRoleEnum.admin,
value: InviteeRoleEnum.admin,
label: t('Admin'),
},
{
value: MemberRoleEnum.member,
value: InviteeRoleEnum.member,
label: t('Member'),
},
]}
Expand Down
Loading