Skip to content
Open
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
136 changes: 132 additions & 4 deletions apps/admin-x-framework/src/api/members.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {InfiniteData} from '@tanstack/react-query';
import {InfiniteData, useQueryClient} from '@tanstack/react-query';
import {useEffect} from 'react';
import {Meta, createInfiniteQuery, createMutation, createQuery, createQueryWithId} from '../utils/api/hooks';
import {apiUrl} from '../utils/api/fetch-api';

export type MemberLabel = {
id: string;
Expand Down Expand Up @@ -103,12 +105,29 @@ export interface MembersResponseType {
}

const dataType = 'MembersResponseType';
const membersPath = '/members/';

const memberCountSearchParams = {limit: '1'} as const;

export const getMemberCountQueryKey = () => [dataType, apiUrl(membersPath, memberCountSearchParams)] as const;

export const useBrowseMembers = createQuery<MembersResponseType>({
dataType,
path: '/members/'
path: membersPath
});

const useBrowseMemberCount = createQuery<MembersResponseType>({
dataType,
path: membersPath,
defaultSearchParams: memberCountSearchParams
});

export function useMemberCount() {
const {data} = useBrowseMemberCount();

return data?.meta?.pagination.total;
}

export type NewMember = {
email: string;
name?: string | null;
Expand All @@ -123,6 +142,70 @@ export const useAddMember = createMutation<MembersResponseType, NewMember>({
invalidateQueries: {dataType}
});

export type ImportMembersImportLabel = {
name: string;
slug: string;
} | null;

export type ImportMembersAcceptedResponseType = {
meta: {
originalImportSize: number;
stats?: undefined;
import_label?: ImportMembersImportLabel;
};
};

export type ImportMembersCompleteResponseType = {
meta: {
originalImportSize?: number;
stats: {
imported: number;
invalid?: Array<Record<string, string> & {error: string}>;
};
import_label?: ImportMembersImportLabel;
};
};

export type ImportMembersResponseType = ImportMembersAcceptedResponseType | ImportMembersCompleteResponseType;

export function isImportMembersAcceptedResponse(response: ImportMembersResponseType): response is ImportMembersAcceptedResponseType {
return typeof response.meta?.originalImportSize === 'number' && response.meta.stats === undefined;
}

export function isImportMembersCompleteResponse(response: ImportMembersResponseType): response is ImportMembersCompleteResponseType {
return typeof response.meta?.stats?.imported === 'number';
}

export type ImportMembersPayload = {
file: File;
labels?: string[];
mapping?: Record<string, string | null | undefined>;
};

function buildImportMembersFormData({file, labels = [], mapping = {}}: ImportMembersPayload) {
const formData = new FormData();
formData.append('membersfile', file);

for (const label of labels) {
formData.append('labels', label);
}

for (const [key, val] of Object.entries(mapping)) {
if (val) {
formData.append(`mapping[${key}]`, val);
}
}

return formData;
}

export const useImportMembers = createMutation<ImportMembersResponseType, ImportMembersPayload>({
method: 'POST',
path: () => '/members/upload/',
body: buildImportMembersFormData,
invalidateQueries: {dataType}
Comment on lines +202 to +206
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disable retries for member import uploads

When this non-idempotent CSV upload is routed through createMutation, it inherits useFetchApi's default retry = true. In production, a transient network error or lost response after the server has already accepted/processed the POST can cause the same file to be posted again, queueing or applying the import twice; the previous direct fetch did not retry. Please set this mutation's request options to retry: false.

Useful? React with 👍 / 👎.

});

export const getMember = createQueryWithId<MembersResponseType>({
dataType,
path: id => `/members/${id}/`
Expand Down Expand Up @@ -160,9 +243,9 @@ export interface MembersInfiniteResponseType extends MembersResponseType {
isEnd: boolean;
}

export const useBrowseMembersInfinite = createInfiniteQuery<MembersInfiniteResponseType>({
const useBrowseMembersInfiniteQuery = createInfiniteQuery<MembersInfiniteResponseType>({
dataType,
path: '/members/',
path: membersPath,
defaultSearchParams: {
include: 'labels,tiers',
limit: '100',
Expand Down Expand Up @@ -190,6 +273,51 @@ export const useBrowseMembersInfinite = createInfiniteQuery<MembersInfiniteRespo
}
});

type BrowseMembersInfiniteOptions = Parameters<typeof useBrowseMembersInfiniteQuery>[0];
type BrowseMembersInfiniteResult = ReturnType<typeof useBrowseMembersInfiniteQuery>;

function hasMemberFilterOrSearch(searchParams?: Record<string, string>) {
return Boolean(searchParams?.filter || searchParams?.search);
}

export function useBrowseMembersInfinite(options: BrowseMembersInfiniteOptions = {}): BrowseMembersInfiniteResult {
const queryClient = useQueryClient();
const result = useBrowseMembersInfiniteQuery(options);
const responseTotalMembers = result.data?.meta?.pagination?.total;

useEffect(() => {
if (hasMemberFilterOrSearch(options.searchParams) || result.isError || result.isPlaceholderData || result.isPreviousData || typeof responseTotalMembers !== 'number') {
return;
}

const memberCountQueryKey = getMemberCountQueryKey();
const memberCountQueryState = queryClient.getQueryState<MembersResponseType>(memberCountQueryKey);
const memberCountData = memberCountQueryState?.data;
const pagination = memberCountData?.meta?.pagination;

if (!memberCountData || !pagination || pagination.total === responseTotalMembers) {
return;
}

if (memberCountQueryState.dataUpdatedAt > result.dataUpdatedAt) {
return;
}

queryClient.setQueryData<MembersResponseType>(memberCountQueryKey, {
...memberCountData,
meta: {
...memberCountData.meta,
pagination: {
...pagination,
total: responseTotalMembers
}
}
});
}, [options.searchParams, queryClient, responseTotalMembers, result.dataUpdatedAt, result.isError, result.isPlaceholderData, result.isPreviousData]);

return result;
}

// Bulk operations
export interface BulkEditAction {
type: 'addLabel' | 'removeLabel' | 'unsubscribe';
Expand Down
Loading
Loading