diff --git a/jsapp/js/account/organization/membersInviteQuery.ts b/jsapp/js/account/organization/membersInviteQuery.ts new file mode 100644 index 0000000000..92554f4b6a --- /dev/null +++ b/jsapp/js/account/organization/membersInviteQuery.ts @@ -0,0 +1,136 @@ +import { + useQuery, + useQueryClient, + useMutation, +} from '@tanstack/react-query'; +import {fetchPost, fetchGet, fetchPatchUrl, fetchDeleteUrl} from 'js/api'; +import {type OrganizationUserRole, useOrganizationQuery} from './organizationQuery'; +import {QueryKeys} from 'js/query/queryKeys'; +import {endpoints} from 'jsapp/js/api.endpoints'; +import type {FailResponse} from 'jsapp/js/dataInterface'; +import {type OrganizationMember} from './membersQuery'; +import {type Json} from 'jsapp/js/components/common/common.interfaces'; + +/* + * 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. + */ +enum MemberInviteStatus { + accepted = 'accepted', + cancelled = 'cancelled', + complete = 'complete', + declined = 'declined', + expired = 'expired', + failed = 'failed', + in_progress = 'in_progress', + 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; + status: MemberInviteStatus; + /** Username of user being invited. */ + invitee: string; + /** Target role of user being invited. */ + invitee_role: OrganizationUserRole; + /** Date format `yyyy-mm-dd HH:MM:SS`. */ + date_created: string; + /** Date format: `yyyy-mm-dd HH:MM:SS`. */ + date_modified: string; +} + +interface SendMemberInviteParams { + /** List of usernames. */ + invitees: string[]; + /** Target role for the invitied users. */ + role: OrganizationUserRole; +} + +/** + * 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 orgQuery = useOrganizationQuery(); + const orgId = orgQuery.data?.id; + return useMutation({ + mutationFn: async (payload: SendMemberInviteParams & Json) => { + const apiPath = endpoints.ORG_MEMBER_INVITES_URL.replace(':organization_id', orgId!); + 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) => { + const apiPath = endpoints.ORG_MEMBER_INVITE_DETAIL_URL + .replace(':organization_id', orgId!) + .replace(':invite_id', inviteId); + return useQuery({ + queryFn: () => fetchGet(apiPath), + queryKey: [QueryKeys.organizationMemberInviteDetail, apiPath], + }); +}; + +/** + * 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). + */ +export function usePatchMemberInvite(inviteUrl: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (newInviteData: Partial) => { + fetchPatchUrl(inviteUrl, newInviteData); + }, + onSettled: () => { + queryClient.invalidateQueries({queryKey: [ + QueryKeys.organizationMemberInviteDetail, + QueryKeys.organizationMembers, + ]}); + }, + }); +} diff --git a/jsapp/js/account/organization/membersQuery.ts b/jsapp/js/account/organization/membersQuery.ts index cc3d736187..077f3a03fe 100644 --- a/jsapp/js/account/organization/membersQuery.ts +++ b/jsapp/js/account/organization/membersQuery.ts @@ -18,6 +18,8 @@ import {endpoints} from 'js/api.endpoints'; import type {PaginatedResponse} from 'js/dataInterface'; import {QueryKeys} from 'js/query/queryKeys'; import type {PaginatedQueryHookParams} from 'jsapp/js/universalTable/paginatedQueryUniversalTable.component'; +import type {MemberInvite} from './membersInviteQuery'; +import type {Json} from 'jsapp/js/components/common/common.interfaces'; import {useSession} from 'jsapp/js/stores/useSession'; export interface OrganizationMember { @@ -38,15 +40,7 @@ export interface OrganizationMember { user__is_active: boolean; /** yyyy-mm-dd HH:MM:SS */ date_joined: string; - invite?: { - /** '/api/v2/organizations//invites//' */ - url: string; - /** yyyy-mm-dd HH:MM:SS */ - date_created: string; - /** yyyy-mm-dd HH:MM:SS */ - date_modified: string; - status: 'sent' | 'accepted' | 'expired' | 'declined'; - }; + invite?: MemberInvite; } function getMemberEndpoint(orgId: string, username: string) { @@ -72,7 +66,7 @@ export function usePatchOrganizationMember(username: string) { // 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(orgId!, username), data), + fetchPatch(getMemberEndpoint(orgId!, username), data as Json), onSettled: () => { // We invalidate query, so it will refetch (instead of refetching it // directly, see: https://github.com/TanStack/query/discussions/2468) diff --git a/jsapp/js/api.endpoints.ts b/jsapp/js/api.endpoints.ts index 063a2b6dcb..c0a957df06 100644 --- a/jsapp/js/api.endpoints.ts +++ b/jsapp/js/api.endpoints.ts @@ -3,6 +3,8 @@ export const endpoints = { ASSET_HISTORY_ACTIONS: '/api/v2/assets/:asset_uid/history/actions', 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/', ME_URL: '/me/', PRODUCTS_URL: '/api/v2/stripe/products/', SUBSCRIPTION_URL: '/api/v2/stripe/subscriptions/', diff --git a/jsapp/js/query/queryKeys.ts b/jsapp/js/query/queryKeys.ts index 1ded3e7116..a6cfbc6acc 100644 --- a/jsapp/js/query/queryKeys.ts +++ b/jsapp/js/query/queryKeys.ts @@ -12,4 +12,5 @@ export enum QueryKeys { activityLogsFilter = 'activityLogsFilter', organization = 'organization', organizationMembers = 'organizationMembers', + organizationMemberInviteDetail = 'organizationMemberInviteDetail', }