Skip to content
Merged
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
70 changes: 69 additions & 1 deletion apps/admin-x-framework/src/api/emails.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,79 @@
import { createMutation } from '../utils/api/hooks';
import { createMutation, createQueryWithId } from '../utils/api/hooks';
import { postsDataType } from './posts';
import type { Email } from './content-types';
import { z } from 'zod';

export interface EmailsResponseType {
emails: Email[];
}

export const EmailBatchStatusSchema = z.enum(['pending', 'submitting', 'submitted', 'failed']);

export const EmailBatchSchema = z.object({
id: z.string(),
status: EmailBatchStatusSchema,
});

export const EmailBatchesResponseSchema = z.object({
batches: z.array(EmailBatchSchema),
});

export const EmailSendingPhaseSchema = z.enum(['preparing', 'submitting']);

export const EmailSendingProgressSchema = z.object({
completed: z.number().int().nonnegative(),
total: z.number().int().nonnegative(),
estimated_seconds_remaining: z.number().int().nonnegative().nullable(),
});

export const EmailSendingStateSchema = z.discriminatedUnion('status', [
z.object({
status: EmailSendingPhaseSchema,
progress: EmailSendingProgressSchema,
}),
z.object({
status: z.literal('submitted'),
progress: EmailSendingProgressSchema,
}),
z.object({
status: z.literal('failed'),
progress: EmailSendingProgressSchema,
failed_during: EmailSendingPhaseSchema,
}),
]);

export const EmailSendingStatusSchema = z.object({
id: z.string(),
sending: EmailSendingStateSchema,
});

export const EmailStatusesResponseSchema = z.object({
email_statuses: z.array(EmailSendingStatusSchema),
});

export type EmailSendingPhase = z.infer<typeof EmailSendingPhaseSchema>;
export type EmailSendingProgress = z.infer<typeof EmailSendingProgressSchema>;
export type EmailSendingState = z.infer<typeof EmailSendingStateSchema>;
export type EmailSendingStatus = z.infer<typeof EmailSendingStatusSchema>;
export type EmailStatusesResponseType = z.infer<typeof EmailStatusesResponseSchema>;
export type EmailBatch = z.infer<typeof EmailBatchSchema>;
export type EmailBatchesResponseType = z.infer<typeof EmailBatchesResponseSchema>;

const emailStatusesDataType = 'EmailStatusesResponseType';
const emailBatchesDataType = 'EmailBatchesResponseType';

export const useBrowseEmailBatches = createQueryWithId<EmailBatchesResponseType>({
dataType: emailBatchesDataType,
path: (id) => `/emails/${id}/batches/`,
parseResponse: (data) => EmailBatchesResponseSchema.parse(data),
});

export const useEmailSendingStatus = createQueryWithId<EmailStatusesResponseType>({
dataType: emailStatusesDataType,
path: (id) => `/emails/${id}/status/`,
parseResponse: (data) => EmailStatusesResponseSchema.parse(data),
});

/**
* Retry a failed email send.
*
Expand Down
4 changes: 2 additions & 2 deletions apps/admin-x-framework/src/api/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ export interface FeedbackResponseType {
feedback: FeedbackItem[];
}

const dataType = 'FeedbackResponseType';
export const feedbackDataType = 'FeedbackResponseType';

export const usePostFeedbackQuery = createQueryWithId<FeedbackResponseType>({
dataType,
dataType: feedbackDataType,
path: (id) => `/feedback/${id}/`,
});
4 changes: 3 additions & 1 deletion apps/admin-x-framework/src/api/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ export type useBulkEditLinksParameters = {
editedUrl: string;
};

export const linksDataType = 'LinkResponseType';

export const useTopLinks = createQuery<LinkResponseType>({
dataType: 'LinkResponseType',
dataType: linksDataType,
path: '/links/',
});

Expand Down
31 changes: 27 additions & 4 deletions apps/admin-x-framework/src/api/members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ export function useMemberCount() {
// The Ember members-count-cache's TTL; the framework default staleTime (5min)
// is too stale for publish-flow recipient counts.
const MEMBERS_COUNT_STALE_TIME = 60 * 1000;
const noOpMembersCountRefetch = () => Promise.resolve();

const useBrowseMembersCountQuery = createQuery<MembersResponseType>({
dataType,
Expand All @@ -205,6 +206,11 @@ export interface MembersCountResult {
/** `null` while loading and for roles that cannot browse members. */
count: number | null;
isLoading: boolean;
isFetching: boolean;
/** Preserved for flows where an unreadable count must block a destructive action. */
error: unknown;
/** Retries the count without forcing every descriptive count consumer to handle errors. */
refetch: () => Promise<unknown>;
}

/**
Expand All @@ -214,8 +220,10 @@ export interface MembersCountResult {
* for 60 seconds. As in Ember, roles that cannot manage members get
* `count: null` without a request, a nullish filter counts as 0 without a
* request, and request errors resolve to 0 with no error toast. While the
* current user is still loading the result is `{count: null, isLoading: true}`
* so callers can tell it apart from a role that cannot browse members.
* current user is still loading the result has `count: null` and
* `isLoading: true`, so callers can tell it apart from a role that cannot
* browse members. The request error and retry are also exposed for callers
* such as publish limits that cannot safely treat an unreadable count as zero.
*/
export function useMembersCount(filter: string | null | undefined): MembersCountResult {
const { data: currentUser } = useCurrentUser();
Expand All @@ -231,16 +239,31 @@ export function useMembersCount(filter: string | null | undefined): MembersCount
});

if (currentUser === undefined) {
return { count: null, isLoading: true };
return {
count: null,
isLoading: true,
isFetching: false,
error: null,
refetch: noOpMembersCountRefetch,
};
}

if (!enabled || result.isError) {
return { count: canFetch ? 0 : null, isLoading: false };
return {
count: canFetch ? 0 : null,
isLoading: false,
isFetching: enabled && result.isFetching,
error: enabled ? result.error : null,
refetch: enabled ? result.refetch : noOpMembersCountRefetch,
};
}

return {
count: result.data?.meta?.pagination.total ?? null,
isLoading: result.isLoading,
isFetching: result.isFetching,
error: result.error,
refetch: result.refetch,
};
}

Expand Down
15 changes: 11 additions & 4 deletions apps/admin-x-framework/src/api/newsletters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,17 @@ export const useBrowseNewsletters = createInfiniteQuery<
path: '/newsletters/',
parseResponse: (data) => NewslettersResponseSchema.parse(data),
defaultSearchParams: { include: 'count.active_members,count.posts', limit: '50' },
defaultNextPageParams: (lastPage, otherParams) => ({
...otherParams,
page: (lastPage.meta?.pagination.next || 1).toString(),
}),
defaultNextPageParams: (lastPage, otherParams) => {
const nextPage = lastPage.meta?.pagination.next;
if (!nextPage) {
return undefined;
}

return {
...otherParams,
page: nextPage.toString(),
};
},
returnData: (originalData) => {
const { pages } = originalData as InfiniteData<NewslettersResponseType>;
const newsletters = pages.flatMap((page) => page.newsletters);
Expand Down
4 changes: 2 additions & 2 deletions apps/admin-x-framework/src/api/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,8 @@ const memberCountHistoryDataType = 'MemberCountHistoryResponseType';
const topPostsStatsDataType = 'TopPostsStatsResponseType';
const postReferrersDataType = 'PostReferrersResponseType';
const newsletterStatsDataType = 'NewsletterStatsResponseType';
const newsletterBasicStatsDataType = 'NewsletterBasicStatsResponseType';
const newsletterClickStatsDataType = 'NewsletterClickStatsResponseType';
export const newsletterBasicStatsDataType = 'NewsletterBasicStatsResponseType';
export const newsletterClickStatsDataType = 'NewsletterClickStatsResponseType';
const newsletterSubscriberStatsDataType = 'NewsletterSubscriberStatsResponseType';

const postGrowthStatsDataType = 'PostGrowthStatsResponseType';
Expand Down
15 changes: 11 additions & 4 deletions apps/admin-x-framework/src/api/tiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,17 @@ const dataType = 'TiersResponseType';
export const useBrowseTiers = createInfiniteQuery<TiersResponseType & { isEnd: boolean }>({
dataType,
path: '/tiers/',
defaultNextPageParams: (lastPage, otherParams) => ({
...otherParams,
page: (lastPage.meta?.pagination.next || 1).toString(),
}),
defaultNextPageParams: (lastPage, otherParams) => {
const nextPage = lastPage.meta?.pagination.next;
if (!nextPage) {
return undefined;
}

return {
...otherParams,
page: nextPage.toString(),
};
},
returnData: (originalData) => {
const { pages } = originalData as InfiniteData<TiersResponseType>;
const tiers = pages.flatMap((page) => page.tiers);
Expand Down
120 changes: 118 additions & 2 deletions apps/admin-x-framework/test/unit/api/emails.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,127 @@
import { act } from '@testing-library/react';
import { act, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { createTestQueryClient, renderHookWithProviders } from '../../../src/test/test-utils';
import { useRetryEmail } from '../../../src/api/emails';
import {
useBrowseEmailBatches,
useEmailSendingStatus,
useRetryEmail,
} from '../../../src/api/emails';
import { postsDataType } from '../../../src/api/posts';
import { withMockFetch } from '../../utils/mock-fetch';

describe('emails api', () => {
it('reads filtered email batches via the batches endpoint', async () => {
await withMockFetch(
{
json: { batches: [{ id: 'batch-1', status: 'submitting' }] },
headers: { 'content-type': 'application/json' },
},
async (mock) => {
const { result } = renderHookWithProviders(() =>
useBrowseEmailBatches('email-1', {
searchParams: { filter: 'status:submitting', fields: 'id,status', limit: '1' },
}),
);

await waitFor(() => expect(result.current.isSuccess).toBe(true));

const batchRequest = (mock.calls as Array<Parameters<typeof globalThis.fetch>>).find(
([url]) => String(url).includes('/emails/email-1/batches/'),
);
expect(batchRequest).toBeDefined();
const [url, options] = batchRequest!;
const requestUrl = new URL(url as string);
expect(requestUrl.pathname).toBe('/ghost/api/admin/emails/email-1/batches/');
expect(requestUrl.searchParams.get('filter')).toBe('status:submitting');
expect(requestUrl.searchParams.get('fields')).toBe('id,status');
expect(requestUrl.searchParams.get('limit')).toBe('1');
expect(options?.method).toBe('GET');
expect(result.current.data?.batches).toEqual([{ id: 'batch-1', status: 'submitting' }]);
},
);
});

it('rejects malformed email batch responses', async () => {
await withMockFetch(
{
json: { batches: [{ id: 'batch-1', status: 'unknown' }] },
headers: { 'content-type': 'application/json' },
},
async () => {
const { result } = renderHookWithProviders(() =>
useBrowseEmailBatches('email-1', { defaultErrorHandler: false }),
);

await waitFor(() => expect(result.current.isError).toBe(true));

expect(result.current.data).toBeUndefined();
},
);
});

it('reads an email sending status via the status endpoint', async () => {
await withMockFetch(
{
json: {
users: [{ id: 'user-1', roles: [] }],
email_statuses: [
{
id: 'email-1',
sending: {
status: 'submitting',
progress: {
completed: 500,
total: 1000,
estimated_seconds_remaining: 30,
},
},
},
],
},
headers: { 'content-type': 'application/json' },
},
async (mock) => {
const { result } = renderHookWithProviders(() => useEmailSendingStatus('email-1'));

await waitFor(() => expect(result.current.isSuccess).toBe(true));

const statusRequest = (mock.calls as Array<Parameters<typeof globalThis.fetch>>).find(
([url]) => String(url).includes('/emails/email-1/status/'),
);
expect(statusRequest).toBeDefined();
const [url, options] = statusRequest!;
expect(new URL(url as string).pathname).toBe('/ghost/api/admin/emails/email-1/status/');
expect(options?.method).toBe('GET');
expect(result.current.data?.email_statuses[0]?.sending).toEqual({
status: 'submitting',
progress: {
completed: 500,
total: 1000,
estimated_seconds_remaining: 30,
},
});
},
);
});

it('rejects malformed email sending status responses', async () => {
await withMockFetch(
{
json: {},
headers: { 'content-type': 'application/json' },
},
async () => {
const { result } = renderHookWithProviders(() =>
useEmailSendingStatus('email-1', { defaultErrorHandler: false }),
);

await waitFor(() => expect(result.current.isError).toBe(true));

expect(result.current.data).toBeUndefined();
},
);
});

it('retries a failed email via the retry endpoint', async () => {
await withMockFetch(
{
Expand Down
Loading
Loading