Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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-defauls/..`
- 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 {
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 { queryClient } from '#/query/queryClient'
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