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
5 changes: 5 additions & 0 deletions apps/admin-x-framework/src/api/content-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export type Email = {
email_count: number;
status?: 'pending' | 'submitting' | 'submitted' | 'failed';
error?: string | null;
submitted_at?: string | null;
recipient_filter?: string | null;
delivered_count?: number;
failed_count?: number;
feedback_enabled?: boolean;
track_opens?: boolean;
track_clicks?: boolean;
};
Expand Down
84 changes: 84 additions & 0 deletions apps/admin-x-framework/src/api/emails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,87 @@ export const useRetryEmail = createMutation<EmailsResponseType, RetryEmailPayloa
requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }),
invalidateQueries: { dataType: postsDataType },
});

export interface EmailDebugBatch extends EmailBatch {
created_at?: string | null;
member_segment?: string | null;
mailgun_message_id?: string | null;
error_message?: string | null;
error_status_code?: number | null;
count?: { recipients: number };
}

export interface EmailRecipientFailure {
id: string;
severity: 'temporary' | 'permanent';
code: number;
enhanced_code?: string | null;
message: string;
email_recipient?: { member_name?: string | null; member_email?: string | null } | null;
member?: { id: string; name?: string | null; avatar_image?: string | null } | null;
}

export interface EmailAnalyticsJob {
running?: boolean;
lastStarted?: string | null;
lastBegin?: string | null;
lastEventTimestamp?: string | null;
fetchedThrough?: string | null;
lagSeconds?: number | null;
canceled?: boolean;
schedule?: { begin: string; end: string } | null;
}

export interface EmailAnalyticsStatus {
latest?: EmailAnalyticsJob;
latestOpened?: EmailAnalyticsJob;
missing?: EmailAnalyticsJob;
scheduled?: EmailAnalyticsJob;
}

const emailAnalyticsDataType = 'EmailAnalyticsStatus';

export const useEmail = createQueryWithId<EmailsResponseType>({
dataType: 'EmailsResponseType',
path: (id) => `/emails/${id}/`,
});

// The sending-status query parses a minimal projection. Keep diagnostic details
// in a separate query so its schema does not discard the full batch response.
export const useEmailDebugBatches = createQueryWithId<{ batches: EmailDebugBatch[] }>({
dataType: 'EmailDebugBatches',
path: (id) => `/emails/${id}/batches/`,
defaultSearchParams: {
include: 'count.recipients',
limit: 'all',
order: 'status asc, created_at desc',
},
});

export const useEmailRecipientFailures = createQueryWithId<{ failures: EmailRecipientFailure[] }>({
dataType: 'EmailRecipientFailures',
path: (id) => `/emails/${id}/recipient-failures/`,
defaultSearchParams: { include: 'member,email_recipient', limit: 'all' },
});

export const useEmailAnalyticsStatus = createQueryWithId<EmailAnalyticsStatus>({
dataType: emailAnalyticsDataType,
path: (id) => `/emails/${id}/analytics/`,
});

export const useScheduleEmailAnalytics = createMutation<
unknown,
{ id: string; begin?: string; end?: string }
>({
method: 'PUT',
path: ({ id }) => `/emails/${id}/analytics/`,
searchParams: ({ begin, end }) => ({ ...(begin ? { begin } : {}), ...(end ? { end } : {}) }),
body: () => ({}),
invalidateQueries: { dataType: emailAnalyticsDataType },
});

export const useCancelEmailAnalytics = createMutation<unknown, void>({
method: 'DELETE',
path: () => '/emails/analytics/',
invalidateQueries: { dataType: emailAnalyticsDataType },
});
31 changes: 31 additions & 0 deletions apps/admin/src/editor/editor-feature-image.acceptance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,37 @@ describe('Post editor feature image', () => {
});
});

it('holds a new image on a published post until Update, then sends it once', async () => {
const saveApi = fakeSavablePost({
status: 'published',
published_at: '2026-01-01T00:00:00.000Z',
});
const uploadApi = fakeAdminEndpoint('POST', '/images/upload/', {
images: [{ url: UPLOADED, ref: null }],
});
await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON);

await expect.element(editorScreen.updateButton()).toBeDisabled();
await userEvent.upload(
editorScreen.featureImageInput().element(),
new File(['image'], 'hills.png', { type: 'image/png' }),
);

await expect.poll(() => uploadApi.requests.length, SAVE_POLL).toBe(1);
await expect.element(editorScreen.removeFeatureImage()).toBeVisible();
await expect.element(editorScreen.updateButton()).toBeEnabled();
expect(saveApi.requests).toHaveLength(0);

await editorScreen.updateButton().click();

await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1);
expect(submittedPost(saveApi)).toMatchObject({
id: POST_ID,
status: 'published',
feature_image: UPLOADED,
});
});

it('saves an image picked from Unsplash with the credit it carries', async () => {
const saveApi = fakeSavablePost();
fakeUnsplashPhotos();
Expand Down
24 changes: 19 additions & 5 deletions apps/admin/src/editor/editor-header-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ export function EditorHeaderActions({
await requireSaved(session.saveExplicit());
}, [session]);

const isSaving = session.state.kind === 'saving' || session.state.kind === 'pending-coalesced';
const isSaving =
session.state.kind === 'preparing' ||
session.state.kind === 'saving' ||
session.state.kind === 'pending-coalesced';
const isContributor = !!currentUser && isContributorUser(currentUser);

// A post the server has never seen can be neither published nor previewed.
Expand All @@ -119,9 +122,14 @@ export function EditorHeaderActions({
};

return (
<Inline data-testid={editorHeaderActions} gap="sm">
<Inline data-testid={editorHeaderActions} gap="md">
{isDraft ? (
<PageHeader.Action fallbackSize="sm" label="Preview" onClick={openPreview}>
<PageHeader.Action
className="bg-background/80 backdrop-blur-sm"
fallbackSize="sm"
label="Preview"
onClick={openPreview}
>
Preview
</PageHeader.Action>
) : null}
Expand Down Expand Up @@ -232,14 +240,19 @@ function PublishActions({
{inputs.error ? (
<>
<Text
className="text-destructive"
className="bg-background/80 text-destructive backdrop-blur-sm"
data-testid={editorPublishInputsError}
role="alert"
size="sm"
>
{inputs.error.message}
</Text>
<Button size={isAdmin7 ? 'default' : 'sm'} variant="ghost" onClick={inputs.retry}>
<Button
className="bg-background/80 backdrop-blur-sm"
size={isAdmin7 ? 'default' : 'sm'}
variant="ghost"
onClick={inputs.retry}
>
Retry
</Button>
</>
Expand All @@ -262,6 +275,7 @@ function PublishActions({
{/* Ember routes a sent post to the update flow from its status line, not the header. */}
{post.status === 'sent' ? null : (
<Button
className="bg-background/80 backdrop-blur-sm"
size={isAdmin7 ? 'default' : 'sm'}
variant="outline"
onClick={() => onOpenFlow('update')}
Expand Down
4 changes: 3 additions & 1 deletion apps/admin/src/editor/editor-header.acceptance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
settingsResponse,
staffRole,
submittedPost,
withoutAutosave,
type StaffRoleName,
} from '@test-utils/acceptance';
import { editorScreen } from '@/editor/editor.screen';
Expand Down Expand Up @@ -561,7 +562,8 @@ describe('Editor header actions', () => {
it('saves unsaved work before the publish it carries', async () => {
publishChrome();
const saveApi = fakeSavablePost();
await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON);
// Only the publish flow's explicit save should win this race, not the autosave timer.
await renderAdminApp(`/editor/post/${POST_ID}`, withoutAutosave(FLAG_ON));

await expect.element(editorScreen.body()).toHaveTextContent('Hello from React');
await typeIntoBody(' and more');
Expand Down
Loading
Loading