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
9 changes: 8 additions & 1 deletion apps/admin-x-framework/src/api/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ export interface ImagesResponseType {
}[];
}

export const useUploadImage = createMutation<ImagesResponseType, { file: File }>({
export interface UploadImagePayload {
file: File;
/** False when the caller handles an expired session itself instead of leaving the page. */
sessionExpiryRedirect?: boolean;
}

export const useUploadImage = createMutation<ImagesResponseType, UploadImagePayload>({
method: 'POST',
path: () => '/images/upload/',
body: ({ file }) => {
Expand All @@ -17,6 +23,7 @@ export const useUploadImage = createMutation<ImagesResponseType, { file: File }>
formData.append('purpose', 'image');
return formData;
},
requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }),
});

const UploadedImageResponseSchema = z.object({
Expand Down
13 changes: 11 additions & 2 deletions apps/admin-x-framework/src/hooks/use-koenig-file-upload.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useRef, useState } from 'react';
import { getGhostPaths } from '../utils/helpers';
import { useFetchApi } from '../utils/api/fetch-api';
import { useFetchApi, type RequestOptions } from '../utils/api/fetch-api';

export const koenigFileUploadTypes = {
image: {
Expand Down Expand Up @@ -55,6 +55,10 @@ interface UploadOptions {
formData?: Record<string, string | Blob>;
}

type UploadRequestOptions = Pick<RequestOptions, 'sessionExpiryRedirect'>;

const DEFAULT_REQUEST_OPTIONS: UploadRequestOptions = {};

interface UploadError {
fileName: string;
message: string;
Expand Down Expand Up @@ -86,7 +90,11 @@ const getStringAtPath = (maybeObj: unknown, path: Iterable<PropertyKey>): null |
return typeof current === 'string' ? current : null;
};

export const useKoenigFileUpload = (type: KoenigFileUploadType = 'image'): FileUploadHook => {
/** The session-expiry policy applies to every upload this hook makes. */
export const useKoenigFileUpload = (
type: KoenigFileUploadType = 'image',
requestOptions: UploadRequestOptions = DEFAULT_REQUEST_OPTIONS,
): FileUploadHook => {
const [progress, setProgress] = useState(0);
const [isLoading, setLoading] = useState(false);
const [errors, setErrors] = useState<UploadError[]>([]);
Expand Down Expand Up @@ -164,6 +172,7 @@ export const useKoenigFileUpload = (type: KoenigFileUploadType = 'image'): FileU

try {
const uploadResponse = await fetchApi(url, {
...requestOptions,
method: koenigFileUploadTypes[type].requestMethod,
body: fileFormData,
onUploadProgress(uploadProgress) {
Expand Down
23 changes: 0 additions & 23 deletions apps/admin-x-framework/test/unit/api/images.test.ts

This file was deleted.

98 changes: 98 additions & 0 deletions apps/admin-x-framework/test/unit/api/images.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { renderHook, waitFor } from '@testing-library/react';
import React, { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { withMockFetch } from '../../utils/mock-fetch';
import { FrameworkProvider } from '../../../src/providers/framework-provider';

const fetchApiCalls = vi.hoisted(() => [] as { endpoint: unknown; options: RequestOptionsLike }[]);

interface RequestOptionsLike {
sessionExpiryRedirect?: boolean;
}

// Wraps the real transport so requests still run, and records what each call asked for
vi.mock('../../../src/utils/api/fetch-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/utils/api/fetch-api')>();
return {
...actual,
useFetchApi: () => {
const fetchApi = actual.useFetchApi();
return (endpoint: unknown, options: RequestOptionsLike = {}) => {
fetchApiCalls.push({ endpoint, options });
return (fetchApi as (...args: unknown[]) => Promise<unknown>)(endpoint, options);
};
},
};
});

import { getImageUrl, useUploadImage } from '../../../src/api/images';

const wrapper: React.FC<{ children: ReactNode }> = ({ children }) => (
<FrameworkProvider
externalNavigate={() => {}}
ghostVersion="5.x"
sentryDSN=""
unsplashConfig={{
Authorization: '',
'Accept-Version': '',
'Content-Type': '',
'App-Pragma': '',
'X-Unsplash-Cache': true,
}}
onDelete={() => {}}
onInvalidate={() => {}}
onUpdate={() => {}}
>
{children}
</FrameworkProvider>
);

describe('getImageUrl', () => {
it.each(['https://example.com/image.png', '/content/images/image.png'])(
'accepts an uploaded image URL (%s)',
(url) => {
expect(getImageUrl({ images: [{ url, ref: null }] })).toBe(url);
},
);

it.each([
null,
{},
{ images: [] },
{ images: [{}] },
{ images: [{ url: 123 }] },
{ images: [{ url: true }] },
{ images: [{ url: '' }] },
])('rejects a malformed upload response (%j)', (response) => {
expect(() => getImageUrl(response)).toThrow();
});
});

describe('useUploadImage', () => {
const uploaded = { images: [{ url: 'https://example.com/image.png', ref: null }] };

const upload = async (payload: { file: File; sessionExpiryRedirect?: boolean }) => {
fetchApiCalls.length = 0;
await withMockFetch({ json: uploaded }, async () => {
const { result } = renderHook(() => useUploadImage(), { wrapper });
await waitFor(() => expect(result.current.mutateAsync).toBeTypeOf('function'));
await result.current.mutateAsync(payload);
});
return fetchApiCalls[0]?.options;
};

it('keeps the session-expiry redirect when the caller asks for nothing', async () => {
const options = await upload({ file: new File(['image'], 'hills.png') });

expect(options?.sessionExpiryRedirect).toBeUndefined();
});

it('passes the session-expiry opt-out to the transport', async () => {
const options = await upload({
file: new File(['image'], 'hills.png'),
sessionExpiryRedirect: false,
});

expect(options?.sessionExpiryRedirect).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ import { type AddressInfo } from 'node:net';
import http from 'node:http';
import { promisify } from 'node:util';
import * as helpers from '../../../src/utils/helpers';

interface RequestOptionsLike {
sessionExpiryRedirect?: boolean;
}

const fetchApiCalls = vi.hoisted(() => [] as RequestOptionsLike[]);

// Wraps the real transport so uploads still run, and records what each call asked for
vi.mock('../../../src/utils/api/fetch-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/utils/api/fetch-api')>();
return {
...actual,
useFetchApi: () => {
const fetchApi = actual.useFetchApi();
return (endpoint: unknown, options: RequestOptionsLike = {}) => {
fetchApiCalls.push(options);
return (fetchApi as (...args: unknown[]) => Promise<unknown>)(endpoint, options);
};
},
};
});

import { useKoenigFileUpload } from '../../../src/hooks/use-koenig-file-upload';

function makeFile(name: string, type = 'image/jpeg'): File {
Expand Down Expand Up @@ -59,6 +81,7 @@ describe('useKoenigFileUpload', () => {
beforeEach(async () => {
uploadResponse = successfulUploadResponse;
requestLog = [];
fetchApiCalls.length = 0;

server = http.createServer((req, res) => {
requestLog.push({ method: req.method, url: req.url });
Expand Down Expand Up @@ -303,6 +326,28 @@ describe('useKoenigFileUpload', () => {
expect(result.current.errors).toHaveLength(0);
});

it('keeps the session-expiry redirect when the caller asks for nothing', async () => {
const { result } = renderHook(() => useKoenigFileUpload('image'));

await act(async () => {
await result.current.upload([makeFile('photo.jpg')]);
});

expect(fetchApiCalls[0]?.sessionExpiryRedirect).toBeUndefined();
});

it('passes the session-expiry opt-out to the transport', async () => {
const { result } = renderHook(() =>
useKoenigFileUpload('image', { sessionExpiryRedirect: false }),
);

await act(async () => {
await result.current.upload([makeFile('photo.jpg')]);
});

expect(fetchApiCalls[0]?.sessionExpiryRedirect).toBe(false);
});

it('accepts all supported image extensions', async () => {
const supportedExtensions = ['gif', 'jpg', 'jpeg', 'png', 'svg', 'svgz', 'webp'];

Expand Down
2 changes: 2 additions & 0 deletions apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
"@codemirror/state": "catalog:",
"@codemirror/theme-one-dark": "catalog:",
"@dnd-kit/sortable": "catalog:",
"@radix-ui/react-focus-guards": "catalog:",
"@radix-ui/react-focus-scope": "catalog:",
"@sentry/react": "catalog:",
"@svg-maps/world": "2.0.0",
"@tanstack/react-query": "catalog:",
Expand Down
55 changes: 54 additions & 1 deletion apps/admin/src/editor/editor-feature-image.acceptance.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { describe, expect, it } from 'vitest';
import { userEvent } from 'vitest/browser';
import { page, userEvent } from 'vitest/browser';

import {
UNSPLASH_PICKED,
currentRoute,
fakeAdminEndpoint,
fakeEditorChrome,
fakeEditorPost,
fakeUnsplashPhotos,
post,
renderAdminApp,
submittedPost,
Expand Down Expand Up @@ -200,6 +203,27 @@ describe('Post editor feature image', () => {
SLOW,
);

it(
'saves an image picked from Unsplash with the credit it carries',
async () => {
const saveApi = fakeSavablePost();
fakeUnsplashPhotos();
await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON);

await expect.element(editorScreen.featureImageUnsplashButton()).toBeVisible();
await editorScreen.featureImageUnsplashButton().click();
await editorScreen.unsplashInsertImage().click();

await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1);
const saved = submittedPost(saveApi);
expect(saved.feature_image).toBe(UNSPLASH_PICKED);
// The photographer credit the picker hands over, as the caption stores it.
expect(String(saved.feature_image_caption)).toContain('A Photographer');
await expect.element(editorScreen.removeFeatureImage()).toBeVisible();
},
SLOW,
);

it(
'clears the alt text and caption along with the image',
async () => {
Expand All @@ -222,4 +246,33 @@ describe('Post editor feature image', () => {
},
SLOW,
);

it(
'stays in the editor when the upload finds no session',
async () => {
fakeSavablePost();
const uploadApi = fakeAdminEndpoint(
'POST',
'/images/upload/',
{ errors: [{ type: 'UnauthorizedError', message: 'Authorization failed' }] },
{ status: 401 },
);
await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON);

await expect.element(editorScreen.featureImage()).toBeVisible();
await editorScreen.titleInput().fill('Brand New Name');
await userEvent.upload(
editorScreen.featureImageInput().element(),
new File(['image'], 'hills.png', { type: 'image/png' }),
);

// A 401 mid-upload must not navigate away from work that is still unsaved.
await expect.poll(() => uploadApi.requests.length, SAVE_POLL).toBe(1);
await expect.element(editorScreen.titleInput()).toHaveValue('Brand New Name');
expect(currentRoute()).toBe(`/editor/post/${POST_ID}`);
await expect.element(editorScreen.featureImageInput()).toBeInTheDocument();
await expect.element(page.getByText('Couldn’t upload the feature image.')).toBeVisible();
},
SLOW,
);
});
4 changes: 2 additions & 2 deletions apps/admin/src/editor/editor-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,8 @@ function useLexicalConversion(postType: PostType) {
try {
const record: EditorRecord | undefined =
postType === 'page'
? (await editPage({ page: payload, options })).pages[0]
: (await editPost({ post: payload, options })).posts[0];
? (await editPage({ page: payload, options, ...EDITOR_REQUEST_OPTIONS })).pages[0]
: (await editPost({ post: payload, options, ...EDITOR_REQUEST_OPTIONS })).posts[0];
setState(record ? { id: source.id, record } : { id: source.id, error: true });
} catch (error) {
setState({ id: source.id, error });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,13 @@ describe('Post settings access', () => {
await chooseVisibility('Specific tier(s)');

// Nothing is selected yet, so the choice is held back rather than stripped.
await expect.element(editorScreen.settingsTiersError()).toBeVisible();
await expect
.element(editorScreen.settingsTiersError())
.toHaveTextContent('Please select at least one tier');
await expect.element(editorScreen.settingsTiers()).toHaveAttribute('aria-invalid', 'true');
await expect
.element(editorScreen.settingsTiers())
.toHaveAttribute('aria-describedby', editorScreen.settingsTiersError().element().id);
expect(saveApi.requests).toHaveLength(0);
// Archived paid tiers are offered after the active ones; free tiers are not.
await expect.element(editorScreen.settingsTier('Bronze')).toBeVisible();
Expand Down
Loading
Loading