Skip to content

Commit 5769cd3

Browse files
authored
Merge branch 'main' into feedbin-inbox-link
2 parents 7407779 + c7eaf27 commit 5769cd3

File tree

32 files changed

+967
-485
lines changed

32 files changed

+967
-485
lines changed

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,9 @@
1616
#STRIPE_PUBLISHABLE_KEY=
1717
## Stripe Account ID: acct_1*******
1818
#STRIPE_ACCOUNT_ID=
19+
20+
# Mailgun SMTP credentials - used with `yarn dev:mailgun`
21+
## SMTP username from Mailgun (often starts with `postmaster@`)
22+
# MAILGUN_SMTP_USER=
23+
## SMTP password from Mailgun
24+
# MAILGUN_SMTP_PASS=

apps/admin-x-design-system/src/utils/format-url.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import isEmail from 'validator/es/lib/isEmail';
1+
import isEmail from 'validator/es/lib/isEmail.js';
22

33
export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) => {
44
if (nullable && !value) {

apps/admin-x-framework/src/hooks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export type {Dirtyable, ErrorMessages, FormHook, OkProps, SaveHandler, SaveState
44
export {default as useHandleError} from './hooks/use-handle-error';
55
export {usePermission} from './hooks/use-permissions';
66
export {useKoenigFileUpload, koenigFileUploadTypes} from './hooks/use-koenig-file-upload';
7+
export {useKoenigFetchEmbed} from './hooks/use-koenig-fetch-embed';
78
export type {KoenigFileUploadType} from './hooks/use-koenig-file-upload';
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import {useCallback} from 'react';
2+
import {getGhostPaths} from '../utils/helpers';
3+
import {useFetchApi} from '../utils/api/fetch-api';
4+
5+
interface KoenigFetchEmbedOptions {
6+
type?: string;
7+
}
8+
9+
export const useKoenigFetchEmbed = () => {
10+
const fetchApi = useFetchApi();
11+
12+
return useCallback(async (url: string, {type}: KoenigFetchEmbedOptions = {}) => {
13+
const oembedUrl = new URL(`${getGhostPaths().apiRoot}/oembed/`, window.location.origin);
14+
oembedUrl.searchParams.set('url', url);
15+
if (type) {
16+
oembedUrl.searchParams.set('type', type);
17+
}
18+
19+
return await fetchApi(oembedUrl);
20+
}, [fetchApi]);
21+
};

apps/admin-x-framework/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type {UseTinybirdTokenResult} from './hooks/use-tinybird-token';
1717
export {useTinybirdQuery} from './hooks/use-tinybird-query';
1818
export type {UseTinybirdQueryOptions} from './hooks/use-tinybird-query';
1919
export {useKoenigFileUpload, koenigFileUploadTypes} from './hooks/use-koenig-file-upload';
20+
export {useKoenigFetchEmbed} from './hooks/use-koenig-fetch-embed';
2021
export type {KoenigFileUploadType} from './hooks/use-koenig-file-upload';
2122

2223
// Currency utilities

apps/admin-x-framework/src/test/render.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import React from 'react';
33
import ReactDOM from 'react-dom/client';
44
import {TopLevelFrameworkProps} from '../providers/framework-provider';
55

6+
const fetchKoenigLexical: DesignSystemAppProps['fetchKoenigLexical'] = async () => {
7+
// @ts-expect-error koenig-lexical doesn't currently ship TypeScript declarations.
8+
return await import('@tryghost/koenig-lexical');
9+
};
10+
611
export default function renderStandaloneApp<Props extends object>(
712
App: React.ComponentType<Props & {
813
framework: TopLevelFrameworkProps;
@@ -36,7 +41,7 @@ export default function renderStandaloneApp<Props extends object>(
3641
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
3742
<React.StrictMode>
3843
<App
39-
designSystem={{darkMode: false, fetchKoenigLexical: async () => {}}}
44+
designSystem={{darkMode: false, fetchKoenigLexical}}
4045
framework={{
4146
externalNavigate: (link) => {
4247
// Use the expectExternalNavigate helper to test this dummy external linking
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/// <reference types="vitest/globals" />
2+
import {renderHook} from '@testing-library/react';
3+
import {type AddressInfo} from 'node:net';
4+
import http from 'node:http';
5+
import {promisify} from 'node:util';
6+
import * as helpers from '../../../src/utils/helpers';
7+
import {useKoenigFetchEmbed} from '../../../src/hooks/use-koenig-fetch-embed';
8+
9+
interface OEmbedResponse {
10+
type: string;
11+
html: string;
12+
}
13+
14+
interface RequestLog {
15+
method?: string;
16+
url?: string;
17+
headers?: http.IncomingHttpHeaders;
18+
}
19+
20+
describe('useKoenigFetchEmbed', () => {
21+
let server: http.Server;
22+
let baseUrl: string;
23+
let requestLog: RequestLog[];
24+
let responseStatus: number;
25+
let responseBody: OEmbedResponse | {errors: Array<{message: string}>};
26+
27+
beforeEach(async () => {
28+
requestLog = [];
29+
responseStatus = 200;
30+
responseBody = {
31+
type: 'video',
32+
html: '<iframe src="https://example.com/embed"></iframe>'
33+
};
34+
35+
server = http.createServer((req, res) => {
36+
requestLog.push({method: req.method, url: req.url, headers: req.headers});
37+
res.writeHead(responseStatus, {'Content-Type': 'application/json'});
38+
res.end(JSON.stringify(responseBody));
39+
});
40+
41+
await new Promise<void>((resolve) => {
42+
server.listen(0, '127.0.0.1', () => {
43+
resolve();
44+
});
45+
});
46+
47+
const address = server.address() as AddressInfo;
48+
baseUrl = `http://127.0.0.1:${address.port}`;
49+
50+
vi.spyOn(helpers, 'getGhostPaths').mockReturnValue({
51+
subdir: '',
52+
adminRoot: '/ghost/',
53+
assetRoot: '/ghost/assets/',
54+
apiRoot: `${baseUrl}/ghost/api/admin`,
55+
activityPubRoot: '/.ghost/activitypub'
56+
});
57+
});
58+
59+
afterEach(async () => {
60+
const close = promisify(server.close.bind(server));
61+
await close();
62+
vi.restoreAllMocks();
63+
});
64+
65+
it('requests oembed with url parameter', async () => {
66+
const {result} = renderHook(() => useKoenigFetchEmbed());
67+
68+
const embedResult = await result.current('https://ghost.org/');
69+
expect(embedResult).toEqual(responseBody);
70+
71+
const request = requestLog[0];
72+
expect(request?.method).toBe('GET');
73+
expect(request?.headers?.['app-pragma']).toBe('no-cache');
74+
75+
const requestUrl = new URL(request?.url || '', baseUrl);
76+
expect(requestUrl.pathname).toBe('/ghost/api/admin/oembed/');
77+
expect(requestUrl.searchParams.get('url')).toBe('https://ghost.org/');
78+
expect(requestUrl.searchParams.get('type')).toBeNull();
79+
});
80+
81+
it('includes type parameter when provided', async () => {
82+
const {result} = renderHook(() => useKoenigFetchEmbed());
83+
84+
await result.current('https://ghost.org/', {type: 'bookmark'});
85+
86+
const requestUrl = new URL(requestLog[0]?.url || '', baseUrl);
87+
expect(requestUrl.searchParams.get('url')).toBe('https://ghost.org/');
88+
expect(requestUrl.searchParams.get('type')).toBe('bookmark');
89+
});
90+
91+
it('throws when the oembed request fails', async () => {
92+
responseStatus = 500;
93+
responseBody = {
94+
errors: [{message: 'Server failure'}]
95+
};
96+
97+
const {result} = renderHook(() => useKoenigFetchEmbed());
98+
await expect(result.current('https://ghost.org/')).rejects.toThrow(/oembed/i);
99+
});
100+
});

apps/admin-x-settings/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"@codemirror/lang-html": "6.4.11",
4343
"@tryghost/color-utils": "0.2.10",
4444
"@tryghost/i18n": "0.0.0",
45-
"@tryghost/kg-unsplash-selector": "0.3.18",
45+
"@tryghost/kg-unsplash-selector": "0.3.19",
4646
"@tryghost/limit-service": "1.4.1",
4747
"@tryghost/nql": "0.12.10",
4848
"@tryghost/timezone-data": "0.4.12",

apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, {useCallback, useMemo} from 'react';
22
import useFeatureFlag from '../../../../hooks/use-feature-flag';
33
import {KoenigEditorBase, type KoenigInstance, LoadingIndicator} from '@tryghost/admin-x-design-system';
44
import {cn} from '@tryghost/shade';
5-
import {koenigFileUploadTypes, useKoenigFileUpload} from '@tryghost/admin-x-framework/hooks';
5+
import {koenigFileUploadTypes, useKoenigFetchEmbed, useKoenigFileUpload} from '@tryghost/admin-x-framework/hooks';
66
import {useFramework} from '@tryghost/admin-x-framework';
77

88
export interface MemberEmailsEditorProps {
@@ -27,8 +27,12 @@ const MemberEmailsEditor: React.FC<MemberEmailsEditorProps> = ({
2727
}) => {
2828
const welcomeEmailEditorEnabled = useFeatureFlag('welcomeEmailEditor');
2929
const {unsplashConfig} = useFramework();
30+
const fetchEmbed = useKoenigFetchEmbed();
3031

31-
const cardConfig = useMemo(() => ({unsplash: unsplashConfig}), [unsplashConfig]);
32+
const cardConfig = useMemo(() => ({
33+
unsplash: unsplashConfig,
34+
fetchEmbed
35+
}), [unsplashConfig, fetchEmbed]);
3236

3337
const baseEditorStyles = cn(
3438
// Base typography

apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {expect, test} from '@playwright/test';
22
import {globalDataRequests, mockApi, responseFixtures} from '@tryghost/admin-x-framework/test/acceptance';
3+
import type {Page} from '@playwright/test';
34

45
const automatedEmailsFixture = {
56
automated_emails: [{
@@ -22,6 +23,32 @@ const newslettersRequest = {
2223
browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: responseFixtures.newsletters}
2324
};
2425

26+
const configWithWelcomeEmailEditorEnabled = {
27+
...responseFixtures.config,
28+
config: {
29+
...responseFixtures.config.config,
30+
labs: {
31+
...responseFixtures.config.config.labs,
32+
welcomeEmailEditor: true
33+
}
34+
}
35+
};
36+
37+
const pasteText = async (page: Page, content: string) => {
38+
await page.evaluate((text: string) => {
39+
const dataTransfer = new DataTransfer();
40+
dataTransfer.setData('text/plain', text);
41+
42+
document.activeElement?.dispatchEvent(new ClipboardEvent('paste', {
43+
clipboardData: dataTransfer,
44+
bubbles: true,
45+
cancelable: true
46+
}));
47+
48+
dataTransfer.clearData();
49+
}, content);
50+
};
51+
2552
test.describe('Member emails settings', async () => {
2653
test.describe('Welcome email modal', async () => {
2754
test('Escape key closes test email dropdown without closing modal', async ({page}) => {
@@ -196,6 +223,94 @@ test.describe('Member emails settings', async () => {
196223
});
197224
});
198225

226+
test('welcome email editor pastes URL and fetches embed metadata', async ({page}) => {
227+
const {lastApiRequests} = await mockApi({page, requests: {
228+
...globalDataRequests,
229+
...newslettersRequest,
230+
browseConfig: {method: 'GET', path: '/config/', response: configWithWelcomeEmailEditorEnabled},
231+
browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture},
232+
fetchOembed: {
233+
method: 'GET',
234+
path: /^\/oembed\/\?/,
235+
response: {
236+
type: 'video',
237+
html: '<iframe width="200" height="113" src="https://www.youtube.com/embed/8YWl7tDGUPA?feature=oembed" frameborder="0" allowfullscreen></iframe>'
238+
}
239+
}
240+
}});
241+
242+
await page.goto('/#/memberemails');
243+
await page.waitForLoadState('networkidle');
244+
245+
const section = page.getByTestId('memberemails');
246+
await expect(section).toBeVisible({timeout: 10000});
247+
await section.getByTestId('free-welcome-email-preview').click();
248+
249+
const modal = page.getByTestId('welcome-email-modal');
250+
await expect(modal).toBeVisible();
251+
252+
const editor = modal.locator('[data-kg="editor"] div[contenteditable="true"]').first();
253+
await editor.click({timeout: 5000});
254+
await page.keyboard.press('ControlOrMeta+a');
255+
await page.keyboard.press('Backspace');
256+
257+
await pasteText(page, 'https://ghost.org/');
258+
259+
await expect(modal.getByTestId('embed-iframe')).toBeVisible();
260+
261+
await expect.poll(() => lastApiRequests.fetchOembed?.url || '').toContain('/oembed/?');
262+
await expect.poll(() => lastApiRequests.fetchOembed?.url || '').toContain('url=https%3A%2F%2Fghost.org%2F');
263+
});
264+
265+
test('welcome email editor bookmark card fetches bookmark metadata', async ({page}) => {
266+
const {lastApiRequests} = await mockApi({page, requests: {
267+
...globalDataRequests,
268+
...newslettersRequest,
269+
browseConfig: {method: 'GET', path: '/config/', response: configWithWelcomeEmailEditorEnabled},
270+
browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture},
271+
fetchOembed: {
272+
method: 'GET',
273+
path: /^\/oembed\/\?/,
274+
response: {
275+
url: 'https://ghost.org/',
276+
metadata: {
277+
icon: 'https://ghost.org/favicon.ico',
278+
title: 'Ghost: The Creator Economy Platform',
279+
description: 'Build independent publishing businesses and memberships.',
280+
publisher: 'Ghost.org',
281+
author: 'Ghost',
282+
thumbnail: 'https://ghost.org/images/meta/ghost.png'
283+
}
284+
}
285+
}
286+
}});
287+
288+
await page.goto('/#/memberemails');
289+
await page.waitForLoadState('networkidle');
290+
291+
const section = page.getByTestId('memberemails');
292+
await expect(section).toBeVisible({timeout: 10000});
293+
await section.getByTestId('free-welcome-email-preview').click();
294+
295+
const modal = page.getByTestId('welcome-email-modal');
296+
await expect(modal).toBeVisible();
297+
298+
const editor = modal.locator('[data-kg="editor"] div[contenteditable="true"]').first();
299+
await editor.click({timeout: 5000});
300+
await page.keyboard.press('ControlOrMeta+a');
301+
await page.keyboard.press('Backspace');
302+
await page.keyboard.type('/bookmark');
303+
await page.keyboard.press('Enter');
304+
305+
const bookmarkUrlInput = modal.getByTestId('bookmark-url');
306+
await expect(bookmarkUrlInput).toBeVisible();
307+
await bookmarkUrlInput.fill('https://ghost.org/');
308+
await bookmarkUrlInput.press('Enter');
309+
310+
await expect(modal.getByTestId('bookmark-title')).toContainText('Ghost: The Creator Economy Platform');
311+
await expect.poll(() => lastApiRequests.fetchOembed?.url || '').toContain('type=bookmark');
312+
});
313+
199314
test('uses automated email sender fields when populated, even if newsletter differs', async ({page}) => {
200315
const populatedAutomatedEmailsFixture = {
201316
automated_emails: [{

0 commit comments

Comments
 (0)