diff --git a/jsapp/js/account/organization/InviteModal.tsx b/jsapp/js/account/organization/InviteModal.tsx index ede08d86f7..b27c4b6a41 100644 --- a/jsapp/js/account/organization/InviteModal.tsx +++ b/jsapp/js/account/organization/InviteModal.tsx @@ -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(null) + const [role, setRole] = useState(null) async function handleUsernameOrEmailCheck(value: string) { if (value === '' || checkEmailPattern(value)) { @@ -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 = () => { @@ -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 to infer values from data property. - onChange={(value) => handleRoleChange(value as MemberRoleEnum | null)} + onChange={(value) => handleRoleChange(value as InviteeRoleEnum | null)} /> ) diff --git a/jsapp/js/account/organization/MembersRoute.tsx b/jsapp/js/account/organization/MembersRoute.tsx index 05376eaeeb..e04c9878ae 100644 --- a/jsapp/js/account/organization/MembersRoute.tsx +++ b/jsapp/js/account/organization/MembersRoute.tsx @@ -2,24 +2,29 @@ import React, { useState } from 'react' import { Box, Divider, Group, Stack, Text, Title } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' -import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { keepPreviousData } from '@tanstack/react-query' import UniversalTable, { DEFAULT_PAGE_SIZE, type UniversalTableColumn } from '#/UniversalTable' import InviteModal from '#/account/organization/InviteModal' import { getSimpleMMOLabel } from '#/account/organization/organization.utils' import subscriptionStore from '#/account/subscriptionStore' +import type { ErrorObject } from '#/api/models/errorObject' +import type { MemberListResponse } from '#/api/models/memberListResponse' +import { MemberListResponseInviteStatus } from '#/api/models/memberListResponseInviteStatus' import { MemberRoleEnum } from '#/api/models/memberRoleEnum' +import { + getOrganizationsMembersListQueryKey, + useOrganizationsMembersList, +} from '#/api/react-query/organization-members' import { useOrganizationAssumed } from '#/api/useOrganizationAssumed' import ActionIcon from '#/components/common/ActionIcon' import ButtonNew from '#/components/common/ButtonNew' import Avatar from '#/components/common/avatar' import Badge from '#/components/common/badge' import envStore from '#/envStore' -import { QueryKeys } from '#/query/queryKeys' import { formatDate } from '#/utils' import InviteeActionsDropdown from './InviteeActionsDropdown' import MemberActionsDropdown from './MemberActionsDropdown' import MemberRoleSelector from './MemberRoleSelector' -import { type OrganizationMember, type OrganizationMemberListItem, getOrganizationMembers } from './membersQuery' import styles from './membersRoute.module.scss' export default function MembersRoute() { @@ -35,37 +40,45 @@ export default function MembersRoute() { offset: 0, }) - const queryResult = useQuery({ - queryKey: [QueryKeys.organizationMembers, pagination.limit, pagination.offset, organization.id], - queryFn: () => getOrganizationMembers(pagination.limit, pagination.offset, organization.id), - placeholderData: keepPreviousData, - // We might want to improve this in future, for now let's not retry - retry: false, - // The `refetchOnWindowFocus` option is `true` by default, I'm setting it - // here so we don't forget about it. - refetchOnWindowFocus: true, + const membersQuery = useOrganizationsMembersList(organization.id, pagination, { + query: { + queryKey: getOrganizationsMembersListQueryKey(organization.id, pagination), + placeholderData: keepPreviousData, + // We might want to improve this in future, for now let's not retry + retry: false, + // The `refetchOnWindowFocus` option is `true` by default, I'm setting it + // here so we don't forget about it. + refetchOnWindowFocus: true, + }, + request: { + errorMessageDisplay: t('There was an error getting the list.'), + }, }) /** * Checks whether object should be treated as organization member or invitee. * Returns both an invite and member, but one of these will be null depending on status */ - function getMemberOrInviteDetails(obj: OrganizationMemberListItem) { - const invite = obj.invite?.status === 'pending' || obj.invite?.status === 'resent' ? obj.invite : null - const member = invite ? null : ({ ...obj } as OrganizationMember) + function getMemberOrInviteDetails(obj: MemberListResponse) { + const invite = + obj.invite?.status === MemberListResponseInviteStatus.pending || + obj.invite?.status === MemberListResponseInviteStatus.resent + ? obj.invite + : null + const member = invite ? null : ({ ...obj } as MemberListResponse) return { invite, member } } - const columns: Array> = [ + const columns: Array> = [ { key: 'user__extra_details__name', label: t('Name'), - cellFormatter: (obj: OrganizationMemberListItem) => { + cellFormatter: (obj: MemberListResponse) => { const { invite, member } = getMemberOrInviteDetails(obj) return ( { - const { invite, member } = getMemberOrInviteDetails(obj) + cellFormatter: (obj: MemberListResponse) => { + const { invite } = getMemberOrInviteDetails(obj) if (invite) { return } else { @@ -93,16 +106,16 @@ export default function MembersRoute() { key: 'date_joined', label: t('Date added'), size: 140, - cellFormatter: (obj: OrganizationMemberListItem) => { + cellFormatter: (obj: MemberListResponse) => { const { invite, member } = getMemberOrInviteDetails(obj) - return invite ? formatDate(invite.date_created) : formatDate(member!.date_joined) + return invite ? formatDate(invite.created) : formatDate(member!.date_joined) }, }, { key: 'role', label: t('Role'), size: 140, - cellFormatter: (obj: OrganizationMemberListItem) => { + cellFormatter: (obj: MemberListResponse) => { const { invite, member } = getMemberOrInviteDetails(obj) if (member?.role === MemberRoleEnum.owner || !isUserAdminOrOwner) { // If the member is the Owner or @@ -121,7 +134,7 @@ export default function MembersRoute() { if (invite) { return ( { + cellFormatter: (obj: MemberListResponse) => { const { invite, member } = getMemberOrInviteDetails(obj) if (member) { if (member.user__has_mfa_enabled) { @@ -161,7 +174,7 @@ export default function MembersRoute() { label: '', size: 64, isPinned: 'right', - cellFormatter: (obj: OrganizationMemberListItem) => { + cellFormatter: (obj: MemberListResponse) => { const { invite, member } = getMemberOrInviteDetails(obj) // There is no action that can be done on an owner if (member?.role === MemberRoleEnum.owner) { @@ -179,7 +192,7 @@ export default function MembersRoute() { /> ) } else if (invite) { - return + return } return null @@ -220,9 +233,9 @@ export default function MembersRoute() { )} - + columns={columns} - queryResult={queryResult} + queryResult={membersQuery} pagination={pagination} setPagination={setPagination} /> diff --git a/jsapp/js/account/organization/common.ts b/jsapp/js/account/organization/common.ts new file mode 100644 index 0000000000..b508a9e6ef --- /dev/null +++ b/jsapp/js/account/organization/common.ts @@ -0,0 +1,4 @@ +/** + * Note: invites APIs return objects with a URL property with an ID within but no ID property. + */ +export const inviteGuidFromUrl = (url: string) => url.slice(0, -1).split('/').pop()! diff --git a/jsapp/js/account/organization/invites/OrgInviteAcceptedBanner.tsx b/jsapp/js/account/organization/invites/OrgInviteAcceptedBanner.tsx index 4bdb49c425..54be5714a5 100644 --- a/jsapp/js/account/organization/invites/OrgInviteAcceptedBanner.tsx +++ b/jsapp/js/account/organization/invites/OrgInviteAcceptedBanner.tsx @@ -1,8 +1,11 @@ import React, { useEffect, useState } from 'react' - -import { MemberInviteStatus } from '#/account/organization/membersInviteQuery' -import { useOrganizationMemberDetailQuery } from '#/account/organization/membersQuery' +import { InviteStatusChoicesEnum } from '#/api/models/inviteStatusChoicesEnum' +import type { MemberListResponse } from '#/api/models/memberListResponse' import type { OrganizationResponse } from '#/api/models/organizationResponse' +import { + getOrganizationsMembersRetrieveQueryKey, + useOrganizationsMembersRetrieve, +} from '#/api/react-query/organization-members' import Alert from '#/components/common/alert' import { useSafeUsernameStorageKey } from '#/hooks/useSafeUsernameStorageKey' @@ -19,20 +22,26 @@ const BANNER_DISMISSAL_VALUE = 'dismissed' * * Note: this is for a user that is part of an organization (and thus has access to it). */ -export default function OrgInviteAcceptedBanner(props: OrgInviteAcceptedBannerProps) { - const organizationMemberDetailQuery = useOrganizationMemberDetailQuery(props.username, false) +export default function OrgInviteAcceptedBanner({ username, organization }: OrgInviteAcceptedBannerProps) { + const memberQuery = useOrganizationsMembersRetrieve(organization.id, username, { + query: { + queryKey: getOrganizationsMembersRetrieveQueryKey(organization.id, username), + retry: false, + refetchOnWindowFocus: false, + }, + }) const [isBannerDismissed, setIsBannerDismissed] = useState() const [localStorageKeyPrefix, setLocalStorageKeyPrefix] = useState() - const localStorageKey = useSafeUsernameStorageKey(localStorageKeyPrefix, props.username) + const localStorageKey = useSafeUsernameStorageKey(localStorageKeyPrefix, username) // Build local storage prefix when invite data is ready. useEffect(() => { - if (organizationMemberDetailQuery.data?.invite?.url) { + if (memberQuery.data?.status === 200 && memberQuery.data?.data.invite.url) { // For the local storage key we include invite url (it has an id), because otherwise the banner would not appear // when user would leave one organization and join another (or rejoin the same one). - setLocalStorageKeyPrefix(`kpiOrgInviteAcceptedBanner-${organizationMemberDetailQuery.data?.invite?.url}`) + setLocalStorageKeyPrefix(`kpiOrgInviteAcceptedBanner-${memberQuery.data?.data.invite.url}`) } - }, [organizationMemberDetailQuery.data]) + }, [memberQuery.data?.status, (memberQuery.data?.data as MemberListResponse)?.invite?.url]) // Get information whether the banner was already dismissed by user. It requires localStorage key to be ready. useEffect(() => { @@ -50,9 +59,10 @@ export default function OrgInviteAcceptedBanner(props: OrgInviteAcceptedBannerPr if ( // Only show banner to users who are members of MMO organization - !props.organization.is_mmo || + !organization.is_mmo || // Only show banner to users who have accepted the invite - organizationMemberDetailQuery.data?.invite?.status !== MemberInviteStatus.accepted || + memberQuery.data?.status !== 200 || + memberQuery.data?.data.invite?.status !== InviteStatusChoicesEnum.accepted || // Wait for local storage information isBannerDismissed === undefined || // Respect users who dismissed the banner @@ -69,7 +79,7 @@ export default function OrgInviteAcceptedBanner(props: OrgInviteAcceptedBannerPr 'This account is now managed by ##TEAM_OR_ORGANIZATION_NAME##. All projects previously owned by your ' + 'account are currently being transfered and will be owned by ##TEAM_OR_ORGANIZATION_NAME##. This process ' + 'can take up to a few minutes to complete.', - ).replaceAll('##TEAM_OR_ORGANIZATION_NAME##', props.organization.name)} + ).replaceAll('##TEAM_OR_ORGANIZATION_NAME##', organization.name)} ) diff --git a/jsapp/js/account/organization/invites/OrgInviteModal.tsx b/jsapp/js/account/organization/invites/OrgInviteModal.tsx index 754f47df2c..553aa22fa1 100644 --- a/jsapp/js/account/organization/invites/OrgInviteModal.tsx +++ b/jsapp/js/account/organization/invites/OrgInviteModal.tsx @@ -1,19 +1,23 @@ import React, { useState } from 'react' import { Button, FocusTrap, Group, Modal, Stack, Text } from '@mantine/core' -import { - MemberInviteStatus, - useOrgMemberInviteQuery, - usePatchMemberInvite, -} from '#/account/organization/membersInviteQuery' import { getSimpleMMOLabel } from '#/account/organization/organization.utils' import subscriptionStore from '#/account/subscriptionStore' -import { endpoints } from '#/api.endpoints' +import type { ErrorDetail } from '#/api/models/errorDetail' +import { InviteStatusChoicesEnum } from '#/api/models/inviteStatusChoicesEnum' +import type { MemberListResponseInviteStatus } from '#/api/models/memberListResponseInviteStatus' +import { + getOrganizationsInvitesListQueryKey, + getOrganizationsInvitesRetrieveQueryKey, +} from '#/api/react-query/organization-invites' +import { useOrganizationsInvitesRetrieve } from '#/api/react-query/organization-invites' import Alert from '#/components/common/alert' import LoadingSpinner from '#/components/common/loadingSpinner' import envStore from '#/envStore' +import { queryClient } from '#/query/queryClient' import { useSession } from '#/stores/useSession' import { notify } from '#/utils' +import useOrganizationsInvitesPartialUpdate from '../useOrganizationsInvitesPartialUpdate' /** * Displays a modal to a user that got an invitation for joining an organization. There is a possibility to accept or @@ -22,24 +26,36 @@ import { notify } from '#/utils' * Note: this is for a user that is NOT a part of an organization (and thus has no access to it). */ export default function OrgInviteModal(props: { orgId: string; inviteId: string; onUserResponse: () => void }) { - const inviteUrl = endpoints.ORG_MEMBER_INVITE_DETAIL_URL.replace(':organization_id', props.orgId).replace( - ':invite_id', - props.inviteId, - ) - const [isModalOpen, setIsModalOpen] = useState(true) const [awaitingDataRefresh, setAwaitingDataRefresh] = useState(false) - const [userResponseType, setUserResponseType] = useState(null) + const [userResponseType, setUserResponseType] = useState(null) const session = useSession() - const orgMemberInviteQuery = useOrgMemberInviteQuery(props.orgId, props.inviteId, false) - const patchMemberInvite = usePatchMemberInvite(inviteUrl, false) + const orgInvitesQuery = useOrganizationsInvitesRetrieve(props.orgId, props.inviteId) + const orgInvitesPatch = useOrganizationsInvitesPartialUpdate({ + mutation: { + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: getOrganizationsInvitesListQueryKey(variables.organizationId) }) + queryClient.invalidateQueries({ + queryKey: getOrganizationsInvitesRetrieveQueryKey(variables.organizationId, variables.guid), + }) + }, + }, + request: { + notifyAboutError: false, + }, + }) + const handleOrgInvitesPatch = (status: MemberListResponseInviteStatus) => { + return orgInvitesPatch.mutateAsync({ organizationId: props.orgId, guid: props.inviteId, data: { status } }) + } // We handle all the errors through query and BE responses, but for some edge cases we have this: const [miscError, setMiscError] = useState() const mmoLabel = getSimpleMMOLabel(envStore.data, subscriptionStore.activeSubscriptions[0]) // We use `mmoLabel` as fallback until `organization_name` is available at the endpoint - const orgName = orgMemberInviteQuery.data?.organization_name || mmoLabel + // Note that `organization_name` doesn't exist on the OpenAPI schema. + // const orgName = (orgInvitesQuery.data?.status === 200 && orgInvitesQuery.data?.data.organization_name) ?? mmoLabel + const orgName = mmoLabel const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -57,8 +73,8 @@ export default function OrgInviteModal(props: { orgId: string; inviteId: string; const handleDeclineInvite = async () => { try { - setUserResponseType(MemberInviteStatus.declined) - await patchMemberInvite.mutateAsync({ status: MemberInviteStatus.declined }) + setUserResponseType(InviteStatusChoicesEnum.declined) + await handleOrgInvitesPatch(InviteStatusChoicesEnum.declined) handleSuccessfulInviteResponse(t('Invitation successfully declined')) } catch (error) { setMiscError(t('Unknown error while trying to update an invitation')) @@ -68,8 +84,8 @@ export default function OrgInviteModal(props: { orgId: string; inviteId: string; const handleAcceptInvite = async () => { try { - setUserResponseType(MemberInviteStatus.accepted) - await patchMemberInvite.mutateAsync({ status: MemberInviteStatus.accepted }) + setUserResponseType(InviteStatusChoicesEnum.accepted) + await handleOrgInvitesPatch(InviteStatusChoicesEnum.accepted) await handleSuccessfulInviteResponse(t('Invitation successfully accepted'), true) } catch (error) { setMiscError(t('Unknown error while trying to update an invitation')) @@ -84,29 +100,32 @@ export default function OrgInviteModal(props: { orgId: string; inviteId: string; let content: React.ReactNode = null let title: React.ReactNode = null + // TODO: investigate the error flows! + // Case 1: loading data. - if (orgMemberInviteQuery.isLoading) { + if (orgInvitesQuery.isLoading) { content = } // Case 2: failed to get the invitation data from API. - else if (orgMemberInviteQuery.isError) { + else if (orgInvitesQuery.isError) { title = t('Invitation not found') // Fallback message let memberInviteErrorMessage = t('Could not find invitation ##invite_id## from organization ##org_id##') .replace('##invite_id##', props.inviteId) .replace('##org_id##', props.orgId) - if (orgMemberInviteQuery.error?.responseJSON?.detail) { - memberInviteErrorMessage = orgMemberInviteQuery.error.responseJSON.detail + if (orgInvitesQuery.error?.detail) { + memberInviteErrorMessage = orgInvitesQuery.error.detail as string } content = {memberInviteErrorMessage} } // Case 3: failed to accept or decline invitation (API response). - else if (patchMemberInvite.isError) { + else if (orgInvitesPatch.isError) { title = t('Unable to join ##TEAM_OR_ORGANIZATION_NAME##').replace('##TEAM_OR_ORGANIZATION_NAME##', orgName) // Fallback message let patchMemberInviteErrorMessage = t('Failed to respond to invitation') - if (patchMemberInvite.error?.responseJSON?.detail) { - patchMemberInviteErrorMessage = patchMemberInvite.error.responseJSON.detail + // TODO: sort out types + if ((orgInvitesPatch.error as ErrorDetail)?.detail) { + patchMemberInviteErrorMessage = (orgInvitesPatch.error as ErrorDetail).detail } content = ( @@ -137,7 +156,10 @@ export default function OrgInviteModal(props: { orgId: string; inviteId: string; } // Case 3: got the invite, its status is pending, so we display form // We also continue displaying this content while we wait for data to refresh following acceptance - else if (orgMemberInviteQuery.data?.status === MemberInviteStatus.pending || awaitingDataRefresh) { + else if ( + orgInvitesQuery.data?.status === 200 && + (orgInvitesQuery.data?.data.status === InviteStatusChoicesEnum.pending || awaitingDataRefresh) + ) { title = t('Accept invitation to join ##TEAM_OR_ORGANIZATION_NAME##').replace( '##TEAM_OR_ORGANIZATION_NAME##', orgName, @@ -160,7 +182,7 @@ export default function OrgInviteModal(props: { orgId: string; inviteId: string; variant='light' size='lg' onClick={handleDeclineInvite} - loading={userResponseType === MemberInviteStatus.declined} + loading={userResponseType === InviteStatusChoicesEnum.declined} > {t('Decline')} @@ -171,7 +193,7 @@ export default function OrgInviteModal(props: { orgId: string; inviteId: string; onClick={handleAcceptInvite} // We don't use RQ loading state here because we also want spinner to display during // timeout while we give backend time for data transfer - loading={userResponseType === MemberInviteStatus.accepted} + loading={userResponseType === InviteStatusChoicesEnum.accepted} > {t('Accept')} @@ -180,7 +202,7 @@ export default function OrgInviteModal(props: { orgId: string; inviteId: string; ) } // Case 4: got the invite, its status is something else, we display error message - else if (orgMemberInviteQuery.data?.status) { + else if (orgInvitesQuery.data?.status === 200 && orgInvitesQuery.data?.data.status) { title = t('Unable to join ##TEAM_OR_ORGANIZATION_NAME##').replace('##TEAM_OR_ORGANIZATION_NAME##', orgName) content = {t('This invitation is no longer available for a response')} } diff --git a/jsapp/js/account/organization/membersInviteQuery.ts b/jsapp/js/account/organization/membersInviteQuery.ts deleted file mode 100644 index 8c03363727..0000000000 --- a/jsapp/js/account/organization/membersInviteQuery.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { type FetchDataOptions, fetchDeleteUrl, fetchGet, fetchPatchUrl, fetchPost } from '#/api' -import { endpoints } from '#/api.endpoints' -import type { MemberRoleEnum } from '#/api/models/memberRoleEnum' -import { useOrganizationAssumed } from '#/api/useOrganizationAssumed' -import type { Json } from '#/components/common/common.interfaces' -import type { FailResponse } from '#/dataInterface' -import { QueryKeys } from '#/query/queryKeys' -import type { OrganizationMember, OrganizationMemberListItem } from './membersQuery' -/* - * NOTE: `invites` - `membersQuery` holds a list of members, each containing - * an optional `invite` property (i.e. invited users that are not members yet - * will also appear on that list). That's why we have mutation hooks here for - * managing the invites. And each mutation will invalidate `membersQuery` to - * make it refetch. - */ - -/* - * NOTE: `orgId` - we're assuming it is not `undefined` in code below, - * because the parent query (`useOrganizationMembersQuery`) wouldn't be enabled - * without it. Plus all the organization-related UI (that would use this hook) - * is accessible only to logged in users. - */ - -/** - * The source of truth of statuses are at `OrganizationInviteStatusChoices` in - * `kobo/apps/organizations/models.py`. This enum should be kept in sync. - */ -export enum MemberInviteStatus { - accepted = 'accepted', - cancelled = 'cancelled', - declined = 'declined', - expired = 'expired', - pending = 'pending', - resent = 'resent', -} - -export interface MemberInvite { - /** This is `endpoints.ORG_INVITE_URL`. */ - url: string - /** Url of a user that have sent the invite. */ - invited_by: string - organization_name: string - status: MemberInviteStatus - /** Username of user being invited. */ - invitee: string - /** Target role of user being invited. */ - invitee_role: MemberRoleEnum - /** Date format `yyyy-mm-dd HH:MM:SS`. */ - date_created: string - /** Date format: `yyyy-mm-dd HH:MM:SS`. */ - date_modified: string -} - -interface MemberInviteRequestBase { - role: MemberRoleEnum -} - -interface SendMemberInviteParams extends MemberInviteRequestBase { - /** List of usernames. */ - invitees: string[] - /** Target role for the invitied users. */ - role: MemberRoleEnum -} - -interface MemberInviteUpdate extends MemberInviteRequestBase { - status: MemberInviteStatus -} - -/** - * Mutation hook that allows sending invite for given user to join organization - * (of logged in user). It ensures that `membersQuery` will refetch data (by - * invalidation). - */ -export function useSendMemberInvite() { - const queryClient = useQueryClient() - const [organization] = useOrganizationAssumed() - const apiPath = endpoints.ORG_MEMBER_INVITES_URL.replace(':organization_id', organization.id) - return useMutation({ - mutationFn: async (payload: SendMemberInviteParams & Json) => fetchPost(apiPath, payload), - onSettled: () => { - queryClient.invalidateQueries({ queryKey: [QueryKeys.organizationMembers] }) - }, - }) -} - -/** - * Mutation hook that allows removing existing invite. It ensures that - * `membersQuery` will refetch data (by invalidation). - */ -export function useRemoveMemberInvite() { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async (inviteUrl: string) => fetchDeleteUrl(inviteUrl), - onSettled: () => { - queryClient.invalidateQueries({ queryKey: [QueryKeys.organizationMembers] }) - }, - }) -} - -/** - * A hook that gives you a single organization member invite. - */ -export const useOrgMemberInviteQuery = (orgId: string, inviteId: string, displayErrorNotification = true) => { - const apiPath = endpoints.ORG_MEMBER_INVITE_DETAIL_URL.replace(':organization_id', orgId!).replace( - ':invite_id', - inviteId, - ) - const fetchOptions: FetchDataOptions = {} - if (displayErrorNotification) { - fetchOptions.errorMessageDisplay = t('There was an error getting this invitation.') - } else { - fetchOptions.notifyAboutError = false - } - return useQuery({ - queryFn: () => fetchGet(apiPath, fetchOptions), - queryKey: [QueryKeys.organizationMemberInviteDetail, apiPath, fetchOptions], - }) -} - -/** - * Mutation hook that allows patching existing invite. Use it to change - * the status of the invite (e.g. decline invite). It ensures that both - * `membersQuery` and `useOrgMemberInviteQuery` will refetch data (by - * invalidation). - * - * If you want to handle errors in your component, use `displayErrorNotification`. - */ -export function usePatchMemberInvite(inviteUrl?: string, displayErrorNotification = true) { - const queryClient = useQueryClient() - const fetchOptions: FetchDataOptions = {} - if (displayErrorNotification) { - fetchOptions.errorMessageDisplay = t('There was an error updating this invitation.') - } else { - fetchOptions.notifyAboutError = false - } - return useMutation>({ - mutationFn: async (newInviteData: Partial) => { - if (inviteUrl) { - return fetchPatchUrl(inviteUrl, newInviteData, fetchOptions) - } else return null - }, - onMutate: async (mutationData) => { - if (mutationData.role) { - // If we are updating the invitee's role, we want to optimistically update their role in queries for - // the members table list. So we look for their unique invite url and update the relevant query accordingly - const qData = queryClient.getQueriesData({ queryKey: [QueryKeys.organizationMembers] }) - const query = qData.find((q) => - (q[1] as any)?.results?.find((m: OrganizationMemberListItem) => m.invite?.url === inviteUrl), - ) - - if (!query) return - - const queryKey = query[0] - const queryData = query[1] - const item = (queryData as any).results.find((m: OrganizationMemberListItem) => m.invite?.url === inviteUrl) - - item.invite.invitee_role = mutationData.role - queryClient.setQueryData(queryKey, queryData) - } - }, - onSettled: () => { - queryClient.invalidateQueries({ - queryKey: [QueryKeys.organizationMemberInviteDetail], - }) - queryClient.invalidateQueries({ - queryKey: [QueryKeys.organizationMembers], - }) - queryClient.invalidateQueries({ - queryKey: [QueryKeys.organizationMemberDetail], - }) - }, - }) -} diff --git a/jsapp/js/account/organization/membersQuery.ts b/jsapp/js/account/organization/membersQuery.ts deleted file mode 100644 index 2de983ffe3..0000000000 --- a/jsapp/js/account/organization/membersQuery.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { fetchDelete, fetchGet, fetchPatch } from '#/api' -import { endpoints } from '#/api.endpoints' -import type { MemberRoleEnum } from '#/api/models/memberRoleEnum' -import { getOrganizationsRetrieveQueryKey } from '#/api/react-query/organizations' -import { useOrganizationAssumed } from '#/api/useOrganizationAssumed' -import type { Json } from '#/components/common/common.interfaces' -import type { Nullable } from '#/constants' -import type { PaginatedResponse } from '#/dataInterface' -import { QueryKeys } from '#/query/queryKeys' -import { useSession } from '#/stores/useSession' -import type { MemberInvite } from './membersInviteQuery' - -export interface OrganizationMember { - /** - * The url to the member within the organization - * `/api/v2/organizations//members//` - */ - url: string - /** `/api/v2/users//` */ - user: string - user__username: string - /** can be an empty string in some edge cases */ - user__email: string | '' - /** can be an empty string in some edge cases */ - user__extra_details__name: string | '' - role: MemberRoleEnum - user__has_mfa_enabled: boolean - user__is_active: boolean - /** yyyy-mm-dd HH:MM:SS */ - date_joined: string -} - -export interface OrganizationMemberListItem extends Nullable { - invite?: MemberInvite -} - -function getMemberEndpoint(orgId: string, username: string) { - return endpoints.ORGANIZATION_MEMBER_URL.replace(':organization_id', orgId).replace(':username', username) -} - -/** - * Mutation hook for updating organization member. It ensures that all related - * queries refetch data (are invalidated). - */ -export function usePatchOrganizationMember(username: string) { - const queryClient = useQueryClient() - const [organization] = useOrganizationAssumed() - - return useMutation({ - mutationFn: async (data: Partial) => - // We're asserting the `orgId` is not `undefined` here, because the parent - // query (`useOrganizationMembersQuery`) wouldn't be enabled without it. - // Plus all the organization-related UI (that would use this hook) is - // accessible only to logged in users. - fetchPatch(getMemberEndpoint(organization.id, username), data as Json), - onMutate: async (mutationData) => { - if (mutationData.role) { - // If we are updating the user's role, we want to optimistically update their role in queries for - // the members table list. So we look for their username and update the relevant query accordingly - const qData = queryClient.getQueriesData({ queryKey: [QueryKeys.organizationMembers] }) - const query = qData.find((q) => - (q[1] as any)?.results?.find((m: OrganizationMemberListItem) => m.user__username === username), - ) - - if (!query) return - - const queryKey = query[0] - const queryData = query[1] - const item = (queryData as any).results.find((m: OrganizationMemberListItem) => m.user__username === username) - - item.role = mutationData.role - queryClient.setQueryData(queryKey, queryData) - } - }, - onSettled: () => { - // We invalidate query, so it will refetch (instead of refetching it - // directly, see: https://github.com/TanStack/query/discussions/2468) - queryClient.invalidateQueries({ - queryKey: [QueryKeys.organizationMembers], - }) - }, - }) -} - -/** - * Mutation hook for removing member from organization. It ensures that all - * related queries refetch data (are invalidated). - */ -export function useRemoveOrganizationMember() { - const queryClient = useQueryClient() - - const session = useSession() - - const [organization] = useOrganizationAssumed() - - return useMutation({ - mutationFn: async (username: string) => - // We're asserting the `orgId` is not `undefined` here, because the parent - // query (`useOrganizationMembersQuery`) wouldn't be enabled without it. - // Plus all the organization-related UI (that would use this hook) is - // accessible only to logged in users. - fetchDelete(getMemberEndpoint(organization.id, username)), - onSuccess: (_data, username) => { - if (username === session.currentLoggedAccount?.username) { - // If user is removing themselves, we need to clear the session - session.refreshAccount() - queryClient.invalidateQueries({ queryKey: getOrganizationsRetrieveQueryKey(organization.id) }) - } - queryClient.invalidateQueries({ queryKey: [QueryKeys.organizationMembers] }) - }, - }) -} - -/** - * Fetches paginated list of members for given organization. - * This is mainly needed for `useOrganizationMembersQuery`, so you most probably - * would use it through that hook rather than directly. - */ -export async function getOrganizationMembers(limit: number, offset: number, orgId: string) { - const params = new URLSearchParams({ - limit: limit.toString(), - offset: offset.toString(), - }) - - const apiUrl = endpoints.ORGANIZATION_MEMBERS_URL.replace(':organization_id', orgId) - - // Note: little crust ahead of time to make a simpler transition to generated react-query helpers. - return { - status: 200 as const, - data: await fetchGet>(apiUrl + '?' + params, { - errorMessageDisplay: t('There was an error getting the list.'), - }), - } -} - -export function useOrganizationMemberDetailQuery(username: string, notifyAboutError = true) { - const [organization] = useOrganizationAssumed() - // `orgId!` because it's ensured to be there in `enabled` property :ok: - const apiPath = endpoints.ORGANIZATION_MEMBER_URL.replace(':organization_id', organization.id).replace( - ':username', - username, - ) - return useQuery({ - queryFn: () => fetchGet(apiPath, { notifyAboutError }), - queryKey: [QueryKeys.organizationMemberDetail, apiPath, notifyAboutError], - retry: false, - refetchOnWindowFocus: false, - }) -} diff --git a/jsapp/js/account/organization/useOrganizationsInvitesPartialUpdate.ts b/jsapp/js/account/organization/useOrganizationsInvitesPartialUpdate.ts new file mode 100644 index 0000000000..8c1fa963e3 --- /dev/null +++ b/jsapp/js/account/organization/useOrganizationsInvitesPartialUpdate.ts @@ -0,0 +1,74 @@ +import { + getOrganizationsInvitesListQueryKey, + getOrganizationsInvitesRetrieveQueryKey, + type organizationsInvitesListResponse, + type organizationsInvitesRetrieveResponse, + useOrganizationsInvitesPartialUpdate as useOrganizationsInvitesPartialUpdateRaw, +} from '#/api/react-query/organization-invites' +import { queryClient } from '#/query/queryClient' + +/** + * TODO: After https://github.com/orval-labs/orval/issues/2297 is resolved, move to a centralized configuration. + */ +export default function useOrganizationsInvitesPartialUpdate(options?: Parameters[0] ) { + return useOrganizationsInvitesPartialUpdateRaw({ + mutation: { + ...options?.mutation, + onMutate: async ({ data, guid, organizationId }) => { + // Optimistic update for invite list + const queryKeyInviteList = getOrganizationsInvitesListQueryKey(organizationId) + await queryClient.cancelQueries({ queryKey: queryKeyInviteList }) + const prevInviteList = queryClient.getQueryData(queryKeyInviteList) + queryClient.setQueryData(queryKeyInviteList, (prev) => { + if (!prev) throw new Error('cannot optimistically update a cache that doesnt exist') + if (prev.status === 404) throw new Error('cannot optimistically update a 404 response') + return { + ...prev, + data: { + ...prev.data, + results: prev.data.results.map((invite) => ({ + ...invite, + status: invite.url.includes(guid) && 'status' in data ? data.status : invite.status, + invitee_role: invite.url.includes(guid) && 'role' in data ? data.role : invite.invitee_role, + })), + }, + } + }) + + // Optimistic update for invite + const queryKeyInvite = getOrganizationsInvitesRetrieveQueryKey(organizationId, guid) + await queryClient.cancelQueries({ queryKey: queryKeyInvite }) + const prevInvite = queryClient.getQueryData(queryKeyInvite) + queryClient.setQueryData(queryKeyInviteList, (prev) => { + if (!prev) throw new Error('cannot optimistically update a cache that doesnt exist') + if (prev.status === 404) throw new Error('cannot optimistically update a 404 response') + return { + ...prev, + data: { + ...prev.data, + status: prev.data.url.includes(guid) && 'status' in data ? data.status : prev.data.status, + invitee_role: prev.data.url.includes(guid) && 'role' in data ? data.role : prev.data.invitee_role, + }, + } + }) + + return { prevInviteList, prevInvite } + }, + onSettled: (_response, error, { organizationId, guid }, context) => { + if (error) { + if (context?.prevInviteList) + queryClient.setQueryData(getOrganizationsInvitesListQueryKey(organizationId), context.prevInviteList) + if (context?.prevInvite) + queryClient.setQueryData(getOrganizationsInvitesRetrieveQueryKey(organizationId, guid), context.prevInvite) + } + queryClient.invalidateQueries({ queryKey: getOrganizationsInvitesListQueryKey(organizationId) }) + queryClient.invalidateQueries({ + queryKey: getOrganizationsInvitesRetrieveQueryKey(organizationId, guid), + }) + }, + }, + request: { + ...options?.request, + }, + }) +} diff --git a/jsapp/js/api.endpoints.ts b/jsapp/js/api.endpoints.ts index e046a3926e..d91b9f144f 100644 --- a/jsapp/js/api.endpoints.ts +++ b/jsapp/js/api.endpoints.ts @@ -11,15 +11,11 @@ export const endpoints = { ASSETS_URL: '/api/v2/assets/', ASSET_URL: '/api/v2/assets/:uid/', ORG_ASSETS_URL: '/api/v2/organizations/:organization_id/assets/', - ORG_MEMBER_INVITES_URL: '/api/v2/organizations/:organization_id/invites/', - ORG_MEMBER_INVITE_DETAIL_URL: '/api/v2/organizations/:organization_id/invites/:invite_id/', ORG_SERVICE_USAGE_URL: '/api/v2/organizations/:organization_id/service_usage/', ME_URL: '/me/', PRODUCTS_URL: '/api/v2/stripe/products/', SUBSCRIPTION_URL: '/api/v2/stripe/subscriptions/', ADD_ONS_URL: '/api/v2/stripe/addons/', - ORGANIZATION_MEMBERS_URL: '/api/v2/organizations/:organization_id/members/', - ORGANIZATION_MEMBER_URL: '/api/v2/organizations/:organization_id/members/:username/', /** Expected parameters: price_id and organization_id **/ CHECKOUT_URL: '/api/v2/stripe/checkout-link', /** Expected parameter: organization_id **/ diff --git a/jsapp/js/components/common/Select.tsx b/jsapp/js/components/common/Select.tsx index c5898555ac..27b6aab13c 100644 --- a/jsapp/js/components/common/Select.tsx +++ b/jsapp/js/components/common/Select.tsx @@ -18,13 +18,18 @@ const iconSizeMap: Record = { xl: 'l', } -const Select = (props: SelectProps) => { - const [value, setValue] = useState(props.value || null) +interface SelectPropsNarrow extends Omit { + value?: Datum + onChange?: (newValue: Datum | null, option: ComboboxItem) => void +} + +const Select = (props: SelectPropsNarrow) => { + const [value, setValue] = useState(props.value || null) const [isOpened, setIsOpened] = useState(props.defaultDropdownOpened || false) const onChange = (newValue: string | null, option: ComboboxItem) => { - setValue(newValue) - props.onChange?.(newValue, option) + setValue(newValue as Datum | null) + props.onChange?.(newValue as Datum | null, option) } const clear = () => { diff --git a/jsapp/js/query/queryKeys.ts b/jsapp/js/query/queryKeys.ts index 8ceae36f00..d4c1fcb588 100644 --- a/jsapp/js/query/queryKeys.ts +++ b/jsapp/js/query/queryKeys.ts @@ -10,10 +10,6 @@ export enum QueryKeys { accessLogs = 'accessLogs', activityLogs = 'activityLogs', activityLogsFilter = 'activityLogsFilter', - organization = 'organization', - organizationMembers = 'organizationMembers', - organizationMemberDetail = 'organizationMemberDetail', - organizationMemberInviteDetail = 'organizationMemberInviteDetail', billingPeriod = 'billingPeriod', serviceUsage = 'serviceUsage', }