diff --git a/apps/admin/src/editor/editor-settings-code-injection.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-code-injection.acceptance.test.tsx index 3b6d74202b3..27a38a0ebe0 100644 --- a/apps/admin/src/editor/editor-settings-code-injection.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-code-injection.acceptance.test.tsx @@ -6,6 +6,8 @@ import { codeInjectionHeadLabel, codeInjectionPageFootLabel, codeInjectionPageHeadLabel, + settingsCodeInjectionBackButton, + settingsCodeInjectionRow, } from '@tryghost/test-data/selectors/editor'; import { @@ -34,8 +36,6 @@ const PAGE_ROUTE = new RegExp(`^/pages/${POST_ID}/\\?`); // The panel's own width, and the width the wide pane widens it to. const PANEL_WIDTH = 350; const WIDE_PANEL_WIDTH = 500; -const BACK_LABEL = 'Close code injection panel'; -const ROW_LABEL = 'Code injection'; const POLL = { timeout: 10_000 }; @@ -102,7 +102,7 @@ function footEditor() { async function openCodeInjection() { await editorScreen.settingsToggle().click(); await expect.element(editorScreen.settingsSidebar()).toBeVisible(); - await editorScreen.settingsSubviewRow(ROW_LABEL).click(); + await editorScreen.settingsSubviewRow(settingsCodeInjectionRow).click(); await expect.element(editorScreen.settingsSubviewPane()).toBeVisible(); await expect.element(headEditor()).toBeVisible(); await expect.element(footEditor()).toBeVisible(); @@ -138,11 +138,11 @@ describe('Post settings code injection', () => { await expect(editorScreen.settingsExcerpt()).toHaveCount(0); expect(sidebarWidthPx()).toBe(WIDE_PANEL_WIDTH); - await editorScreen.settingsSubviewBack(BACK_LABEL).click(); + await editorScreen.settingsSubviewBack(settingsCodeInjectionBackButton).click(); await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); await expect.element(editorScreen.settingsExcerpt()).toBeVisible(); - await expect.element(editorScreen.settingsSubviewRow(ROW_LABEL)).toBeVisible(); + await expect.element(editorScreen.settingsSubviewRow(settingsCodeInjectionRow)).toBeVisible(); // The panel goes back to the width the section list is shown at. await expect.poll(sidebarWidthPx).toBe(PANEL_WIDTH); }); @@ -153,11 +153,13 @@ describe('Post settings code injection', () => { await openCodeInjection(); // Opening a pane leaves the writer on its back button. - await expect.element(editorScreen.settingsSubviewBack(BACK_LABEL)).toHaveFocus(); + await expect + .element(editorScreen.settingsSubviewBack(settingsCodeInjectionBackButton)) + .toHaveFocus(); await userEvent.keyboard('{Escape}'); await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); - await expect.element(editorScreen.settingsSubviewRow(ROW_LABEL)).toBeVisible(); + await expect.element(editorScreen.settingsSubviewRow(settingsCodeInjectionRow)).toBeVisible(); }); it('keeps the pane open on Escape inside an editor', async () => { @@ -196,7 +198,7 @@ describe('Post settings code injection', () => { await renderAdminApp(`/editor/page/${POST_ID}`, FLAG_ON); await editorScreen.settingsToggle().click(); await expect.element(editorScreen.settingsSidebar()).toBeVisible(); - await editorScreen.settingsSubviewRow(ROW_LABEL).click(); + await editorScreen.settingsSubviewRow(settingsCodeInjectionRow).click(); await expect .element(editorScreen.settingsCodeInjection(codeInjectionPageHeadLabel)) @@ -274,14 +276,14 @@ describe('Post settings code injection', () => { await typeInto(headEditor(), ''); await expect.element(headEditor()).toHaveFocus(); - await editorScreen.settingsSubviewBack(BACK_LABEL).click(); + await editorScreen.settingsSubviewBack(settingsCodeInjectionBackButton).click(); await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); await expect(saveApi).toHaveSavedFields({ codeinjection_head: '', }); - await editorScreen.settingsSubviewRow(ROW_LABEL).click(); + await editorScreen.settingsSubviewRow(settingsCodeInjectionRow).click(); await expect.element(headEditor()).toHaveTextContent(''); }); diff --git a/apps/admin/src/editor/editor-settings-facebook-card.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-facebook-card.acceptance.test.tsx index 4064fa8db5b..09acfa31278 100644 --- a/apps/admin/src/editor/editor-settings-facebook-card.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-facebook-card.acceptance.test.tsx @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; import { userEvent } from 'vitest/browser'; +import { + settingsFacebookCardBackButton, + settingsFacebookCardRow, +} from '@tryghost/test-data/selectors/editor'; import { UNSPLASH_PICKED, @@ -25,7 +29,6 @@ const POST_ID = 'abc123'; const CURRENT_USER_ID = '1'; const FLAG_ON = withoutAutosave({ labs: { editorReact: true } }); const PUBLISHED_AT = '2025-12-01T10:00:00.000Z'; -const BACK_LABEL = 'Close Facebook card panel'; const UPLOADED = 'https://example.com/content/images/2026/09/hills.png'; const FEATURE = 'https://example.com/content/images/2026/09/coast.png'; // The site fixture's own description, which the card falls back to last. @@ -69,7 +72,7 @@ function fakeSavablePost(overrides: Partial = {}) { async function openFacebookCard() { await editorScreen.settingsToggle().click(); await expect.element(editorScreen.settingsSidebar()).toBeVisible(); - await editorScreen.settingsSubviewRow('Facebook card').click(); + await editorScreen.settingsSubviewRow(settingsFacebookCardRow).click(); await expect.element(editorScreen.settingsSubviewPane()).toBeVisible(); } @@ -105,9 +108,9 @@ describe('Post settings Facebook card', () => { ); await expect.poll(() => uploadApi.requests.length, POLL).toBe(1); await expect.element(editorScreen.settingsFacebookImageInput()).toBeDisabled(); - await editorScreen.settingsSubviewBack(BACK_LABEL).click(); + await editorScreen.settingsSubviewBack(settingsFacebookCardBackButton).click(); await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); - await editorScreen.settingsSubviewRow('Facebook card').click(); + await editorScreen.settingsSubviewRow(settingsFacebookCardRow).click(); await expect.element(editorScreen.settingsSubviewPane()).toBeVisible(); await expect.element(editorScreen.settingsFacebookImageInput()).toBeDisabled(); await expect.element(editorScreen.settingsFacebookImageUnsplashButton()).toBeDisabled(); @@ -136,13 +139,13 @@ describe('Post settings Facebook card', () => { await expect.element(editorScreen.settingsFacebookTitle()).toBeVisible(); await expect .element(editorScreen.settingsSidebar()) - .toHaveAttribute('aria-label', 'Facebook card'); + .toHaveAttribute('aria-label', settingsFacebookCardRow); - await editorScreen.settingsSubviewBack(BACK_LABEL).click(); + await editorScreen.settingsSubviewBack(settingsFacebookCardBackButton).click(); await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); await expect.element(editorScreen.settingsExcerpt()).toBeVisible(); - await expect.element(editorScreen.settingsSubviewRow('Facebook card')).toBeVisible(); + await expect.element(editorScreen.settingsSubviewRow(settingsFacebookCardRow)).toBeVisible(); }); it('saves an uploaded Facebook image as soon as it lands', async () => { diff --git a/apps/admin/src/editor/editor-settings-keyboard-shortcuts.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-keyboard-shortcuts.acceptance.test.tsx index bb3ddb2b299..b0f27eca16a 100644 --- a/apps/admin/src/editor/editor-settings-keyboard-shortcuts.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-keyboard-shortcuts.acceptance.test.tsx @@ -1,6 +1,10 @@ import { describe, expect, it, onTestFinished } from 'vitest'; import { page, userEvent } from 'vitest/browser'; import { buildLexicalParagraph } from '@tryghost/test-data'; +import { + settingsKeyboardShortcutsBackButton, + settingsKeyboardShortcutsRow, +} from '@tryghost/test-data/selectors/editor'; import { currentUserResponse, @@ -18,8 +22,6 @@ const POST_ID = 'abc123'; const CURRENT_USER_ID = '1'; const FLAG_ON = { labs: { editorReact: true } }; const ROUTE = new RegExp(`^/posts/${POST_ID}/\\?`); -const BACK_LABEL = 'Close keyboard shortcuts panel'; -const ROW_LABEL = 'Keyboard shortcuts'; const MAC_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; const WINDOWS_AGENT = @@ -63,7 +65,7 @@ function fakeEditablePost(overrides: Partial> = {}) { async function openShortcuts() { await editorScreen.settingsToggle().click(); await expect.element(editorScreen.settingsSidebar()).toBeVisible(); - await editorScreen.settingsSubviewRow(ROW_LABEL).click(); + await editorScreen.settingsSubviewRow(settingsKeyboardShortcutsRow).click(); await expect.element(editorScreen.settingsSubviewPane()).toBeVisible(); } @@ -79,14 +81,20 @@ describe('Post settings keyboard shortcuts', () => { // The pane replaces the list it was opened from. await expect(editorScreen.settingsExcerpt()).toHaveCount(0); - await expect.element(editorScreen.settingsSidebar()).toHaveAttribute('aria-label', ROW_LABEL); - await expect.element(page.getByRole('heading', { level: 2, name: ROW_LABEL })).toBeVisible(); + await expect + .element(editorScreen.settingsSidebar()) + .toHaveAttribute('aria-label', settingsKeyboardShortcutsRow); + await expect + .element(page.getByRole('heading', { level: 2, name: settingsKeyboardShortcutsRow })) + .toBeVisible(); - await editorScreen.settingsSubviewBack(BACK_LABEL).click(); + await editorScreen.settingsSubviewBack(settingsKeyboardShortcutsBackButton).click(); await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); await expect.element(editorScreen.settingsExcerpt()).toBeVisible(); - await expect.element(editorScreen.settingsSubviewRow(ROW_LABEL)).toBeVisible(); + await expect + .element(editorScreen.settingsSubviewRow(settingsKeyboardShortcutsRow)) + .toBeVisible(); }); it('lists every shortcut under the group it belongs to, without widening the panel', async () => { @@ -156,7 +164,9 @@ describe('Post settings keyboard shortcuts', () => { await userEvent.keyboard('{Escape}'); await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); - await expect.element(editorScreen.settingsSubviewRow(ROW_LABEL)).toHaveFocus(); + await expect + .element(editorScreen.settingsSubviewRow(settingsKeyboardShortcutsRow)) + .toHaveFocus(); }); it('gives a contributor the same reference list', async () => { diff --git a/apps/admin/src/editor/editor-settings-meta-data.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-meta-data.acceptance.test.tsx index 247a5ff1274..22e9691c089 100644 --- a/apps/admin/src/editor/editor-settings-meta-data.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-meta-data.acceptance.test.tsx @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; import { page, userEvent } from 'vitest/browser'; +import { + settingsMetaDataBackButton, + settingsMetaDataRow, +} from '@tryghost/test-data/selectors/editor'; import { currentUserResponse, @@ -22,7 +26,6 @@ const POST_ID = 'abc123'; const CURRENT_USER_ID = '1'; const FLAG_ON = withoutAutosave({ labs: { editorReact: true } }); const PUBLISHED_AT = '2025-12-01T10:00:00.000Z'; -const BACK_LABEL = 'Close meta data panel'; const PLACEHOLDER = 'Search engines will automatically show a custom preview of content related to the search term here if no custom meta description is set.'; @@ -59,7 +62,7 @@ function fakeSavablePost(overrides: Partial = {}) { async function openMetaData() { await editorScreen.settingsToggle().click(); await expect.element(editorScreen.settingsSidebar()).toBeVisible(); - await editorScreen.settingsSubviewRow('Meta data').click(); + await editorScreen.settingsSubviewRow(settingsMetaDataRow).click(); await expect.element(editorScreen.settingsSubviewPane()).toBeVisible(); } @@ -82,11 +85,11 @@ describe('Post settings meta data', () => { await expect(editorScreen.settingsExcerpt()).toHaveCount(0); await expect.element(editorScreen.settingsMetaTitle()).toBeVisible(); - await editorScreen.settingsSubviewBack(BACK_LABEL).click(); + await editorScreen.settingsSubviewBack(settingsMetaDataBackButton).click(); await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); await expect.element(editorScreen.settingsExcerpt()).toBeVisible(); - await expect.element(editorScreen.settingsSubviewRow('Meta data')).toBeVisible(); + await expect.element(editorScreen.settingsSubviewRow(settingsMetaDataRow)).toBeVisible(); }); it('names the panel after the pane it is showing', async () => { @@ -94,8 +97,12 @@ describe('Post settings meta data', () => { await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); await openMetaData(); - await expect.element(editorScreen.settingsSidebar()).toHaveAttribute('aria-label', 'Meta data'); - await expect.element(page.getByRole('heading', { level: 2, name: 'Meta data' })).toBeVisible(); + await expect + .element(editorScreen.settingsSidebar()) + .toHaveAttribute('aria-label', settingsMetaDataRow); + await expect + .element(page.getByRole('heading', { level: 2, name: settingsMetaDataRow })) + .toBeVisible(); }); it('closes the pane on Escape', async () => { @@ -106,7 +113,7 @@ describe('Post settings meta data', () => { await userEvent.keyboard('{Escape}'); await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); - await expect.element(editorScreen.settingsSubviewRow('Meta data')).toBeVisible(); + await expect.element(editorScreen.settingsSubviewRow(settingsMetaDataRow)).toBeVisible(); }); it('leaves the pane open for an Escape the preview has already answered', async () => { @@ -138,7 +145,7 @@ describe('Post settings meta data', () => { await userEvent.keyboard('{Escape}'); await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); - await expect.element(editorScreen.settingsSubviewRow('Meta data')).toHaveFocus(); + await expect.element(editorScreen.settingsSubviewRow(settingsMetaDataRow)).toHaveFocus(); await expect(saveApi).toHaveSavedFields({ [`meta_${field}`]: 'Saved when the pane closes', }); @@ -151,14 +158,21 @@ describe('Post settings meta data', () => { await openMetaData(); await expect - .poll(() => document.activeElement === editorScreen.settingsSubviewBack(BACK_LABEL).element()) + .poll( + () => + document.activeElement === + editorScreen.settingsSubviewBack(settingsMetaDataBackButton).element(), + ) .toBe(true); - await editorScreen.settingsSubviewBack(BACK_LABEL).click(); + await editorScreen.settingsSubviewBack(settingsMetaDataBackButton).click(); - await expect.element(editorScreen.settingsSubviewRow('Meta data')).toBeVisible(); + await expect.element(editorScreen.settingsSubviewRow(settingsMetaDataRow)).toBeVisible(); await expect - .poll(() => document.activeElement === editorScreen.settingsSubviewRow('Meta data').element()) + .poll( + () => + document.activeElement === editorScreen.settingsSubviewRow(settingsMetaDataRow).element(), + ) .toBe(true); }); @@ -173,7 +187,7 @@ describe('Post settings meta data', () => { await expect.element(editorScreen.settingsSidebar()).toBeVisible(); await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); - await expect.element(editorScreen.settingsSubviewRow('Meta data')).toBeVisible(); + await expect.element(editorScreen.settingsSubviewRow(settingsMetaDataRow)).toBeVisible(); }); it('persists a draft’s meta title and description on the blur that ends each edit', async () => { diff --git a/apps/admin/src/editor/editor-settings-tags.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-tags.acceptance.test.tsx index fefa1a973f0..d3183b1cc2c 100644 --- a/apps/admin/src/editor/editor-settings-tags.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-tags.acceptance.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { userEvent } from 'vitest/browser'; import { buildLexicalParagraph } from '@tryghost/test-data'; +import { settingsTagsCreateText } from '@tryghost/test-data/selectors/editor'; import { currentUserResponse, @@ -185,7 +186,7 @@ describe('Post settings tags', () => { await openTagList(); await editorScreen.settingsTagsInput().fill('Culture'); - await editorScreen.settingsTagOption('Create “Culture”').click(); + await editorScreen.settingsTagOption(`${settingsTagsCreateText} “Culture”`).click(); await expect.poll(() => saveApi.requests.length, POLL).toBe(1); // Named, not created first: an abandoned edit leaves no stray tag behind. @@ -252,7 +253,9 @@ describe('Post settings tags', () => { // A comma is an ordinary character in a tag name, not a separator. await editorScreen.settingsTagsInput().fill('Arts, Culture'); - await expect.element(editorScreen.settingsTagOption(/Create/)).toBeVisible(); + await expect + .element(editorScreen.settingsTagOption(new RegExp(settingsTagsCreateText))) + .toBeVisible(); await userEvent.keyboard('{Tab}'); await expect.poll(() => saveApi.requests.length, POLL).toBe(1); diff --git a/apps/admin/src/editor/editor-settings-x-card.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-x-card.acceptance.test.tsx index c6b56b584d8..fc4d40f1eb5 100644 --- a/apps/admin/src/editor/editor-settings-x-card.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-x-card.acceptance.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { page, userEvent } from 'vitest/browser'; +import { settingsXCardBackButton, settingsXCardRow } from '@tryghost/test-data/selectors/editor'; import { UNSPLASH_PICKED, @@ -25,7 +26,6 @@ const POST_ID = 'abc123'; const CURRENT_USER_ID = '1'; const FLAG_ON = withoutAutosave({ labs: { editorReact: true } }); const PUBLISHED_AT = '2025-12-01T10:00:00.000Z'; -const BACK_LABEL = 'Close X card panel'; const UPLOADED = 'https://example.com/content/images/2026/09/hills.png'; const FEATURE = 'https://example.com/content/images/2026/09/coast.png'; // The site fixture's own description, which the card falls back to last. @@ -75,7 +75,7 @@ function fakeImageUpload() { async function openXCard() { await editorScreen.settingsToggle().click(); await expect.element(editorScreen.settingsSidebar()).toBeVisible(); - await editorScreen.settingsSubviewRow('X card').click(); + await editorScreen.settingsSubviewRow(settingsXCardRow).click(); await expect.element(editorScreen.settingsSubviewPane()).toBeVisible(); } @@ -111,9 +111,9 @@ describe('Post settings X card', () => { ); await expect.poll(() => uploadApi.requests.length, POLL).toBe(1); await expect.element(editorScreen.settingsXImageInput()).toBeDisabled(); - await editorScreen.settingsSubviewBack(BACK_LABEL).click(); + await editorScreen.settingsSubviewBack(settingsXCardBackButton).click(); await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); - await editorScreen.settingsSubviewRow('X card').click(); + await editorScreen.settingsSubviewRow(settingsXCardRow).click(); await expect.element(editorScreen.settingsSubviewPane()).toBeVisible(); await expect.element(editorScreen.settingsXImageInput()).toBeDisabled(); await expect.element(editorScreen.settingsXImageUnsplashButton()).toBeDisabled(); @@ -143,11 +143,11 @@ describe('Post settings X card', () => { await expect.element(editorScreen.settingsXDescription()).toBeVisible(); await expect.element(editorScreen.settingsXImage()).toBeVisible(); - await editorScreen.settingsSubviewBack(BACK_LABEL).click(); + await editorScreen.settingsSubviewBack(settingsXCardBackButton).click(); await expect(editorScreen.settingsSubviewPane()).toHaveCount(0); await expect.element(editorScreen.settingsExcerpt()).toBeVisible(); - await expect.element(editorScreen.settingsSubviewRow('X card')).toBeVisible(); + await expect.element(editorScreen.settingsSubviewRow(settingsXCardRow)).toBeVisible(); }); it('saves an uploaded X image as soon as it lands', async () => { diff --git a/apps/admin/src/editor/editor.screen.ts b/apps/admin/src/editor/editor.screen.ts index e6a82dd3cad..26dbc9627ad 100644 --- a/apps/admin/src/editor/editor.screen.ts +++ b/apps/admin/src/editor/editor.screen.ts @@ -102,6 +102,7 @@ import { stayInEditorButton, tkIndicator, toggleFeatureImageAltButton, + unsplashSearchHeading, unsplashSearchModal, } from '@tryghost/test-data/selectors/editor'; @@ -297,7 +298,7 @@ export const editorScreen = { featureImageInput: () => page.getByLabelText(addFeatureImageLabel), featureImageUnsplashButton: () => page.getByRole('button', { name: featureImageUnsplashButton }), /** The Unsplash search modal, wherever the picker that opened it sits. */ - unsplashModal: () => page.getByRole('heading', { name: 'Unsplash' }), + unsplashModal: () => page.getByRole('heading', { name: unsplashSearchHeading }), unsplashSearch: () => page.getByTestId(unsplashSearchModal), unsplashSearchInput: () => page.getByPlaceholder('Search free high-resolution photos'), unsplashInsertImage: () => page.getByTestId(unsplashSearchModal).getByText('Insert image'), diff --git a/apps/admin/src/editor/engine/construction.test.ts b/apps/admin/src/editor/engine/construction.test.ts new file mode 100644 index 00000000000..c4008986ee8 --- /dev/null +++ b/apps/admin/src/editor/engine/construction.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { slugify } from '@tryghost/string'; +import { createChangeTracker } from './change-tracker'; +import { + createSaveEngine, + type SaveOutcome, + type SaveRequest, + type SaveSnapshot, + type SlugPort, +} from './save-engine'; +import { createSlugMachine } from './slug-machine'; +import { BASE, BASELINE, flush } from './__test-utils__/engine-harness'; + +// React StrictMode may build these twice and drop the first without disposing +// it, so construction must start nothing. +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function spyingEnginePorts() { + const slug = { + settled: vi.fn(() => Promise.resolve()), + fromTitle: vi.fn(() => Promise.resolve({ slug: BASE.slug, source: 'unchanged' as const })), + } satisfies SlugPort; + return { + getSnapshot: vi.fn(() => ({ ...BASE }) as SaveSnapshot), + slug, + prepare: vi.fn((request: SaveRequest) => + Promise.resolve({ ok: true as const, prepared: request }), + ), + execute: vi.fn((prepared: SaveRequest): Promise => + Promise.resolve({ + ok: true, + result: { id: 'post-1', status: prepared.target.status, updatedAt: BASELINE }, + }), + ), + reconcile: vi.fn(), + autosaveDebounceMs: vi.fn(() => undefined), + onStateChange: vi.fn(), + onListenerError: vi.fn(), + }; +} + +describe('createSaveEngine construction', () => { + it('calls no port and arms no timer', async () => { + const ports = spyingEnginePorts(); + const timers = { setTimeout: vi.fn(), clearTimeout: vi.fn() }; + + const engine = createSaveEngine({ ...ports, ...timers }); + await flush(); + + for (const port of [ + ports.getSnapshot, + ports.slug.settled, + ports.slug.fromTitle, + ports.prepare, + ports.execute, + ports.reconcile, + ports.autosaveDebounceMs, + ports.onStateChange, + ports.onListenerError, + timers.setTimeout, + timers.clearTimeout, + ]) { + expect(port).not.toHaveBeenCalled(); + } + expect(vi.getTimerCount()).toBe(0); + expect(engine.getState()).toEqual({ kind: 'idle' }); + }); + + it('leaves a second engine fully working when the first is discarded undisposed', async () => { + const ports = spyingEnginePorts(); + + createSaveEngine(ports); + const engine = createSaveEngine(ports); + expect(vi.getTimerCount()).toBe(0); + + void engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(3000); + + expect(ports.execute).toHaveBeenCalledTimes(1); + expect(ports.reconcile).toHaveBeenCalledTimes(1); + expect(ports.onListenerError).not.toHaveBeenCalled(); + }); +}); + +describe('createSlugMachine construction', () => { + it('requests no slug, reports nothing and arms no timer', async () => { + const generateSlug = vi.fn((text: string) => Promise.resolve(slugify(text))); + const onListenerError = vi.fn(); + + const machine = createSlugMachine({ generateSlug, onListenerError }); + await flush(); + + expect(generateSlug).not.toHaveBeenCalled(); + expect(onListenerError).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + expect(machine.getState().pending).toBe(false); + }); + + it('leaves a second machine fully working when the first is discarded undisposed', async () => { + const generateSlug = vi.fn((text: string) => Promise.resolve(slugify(text))); + const onListenerError = vi.fn(); + + createSlugMachine({ generateSlug, onListenerError }).loaded({ slug: '', title: '' }); + const machine = createSlugMachine({ generateSlug, onListenerError }); + machine.loaded({ slug: '', title: '' }); + + const proposal = await machine.titleCommitted('Hello world'); + + expect(generateSlug).toHaveBeenCalledTimes(1); + expect(proposal).toMatchObject({ slug: 'hello-world', source: 'generated' }); + expect(onListenerError).not.toHaveBeenCalled(); + }); +}); + +describe('createChangeTracker construction', () => { + it('arms no timer', async () => { + createChangeTracker({ siteUrl: 'https://example.com' }); + await flush(); + + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/apps/admin/src/editor/session/README.md b/apps/admin/src/editor/session/README.md index c12369b2de1..46c9486d7bc 100644 --- a/apps/admin/src/editor/session/README.md +++ b/apps/admin/src/editor/session/README.md @@ -9,10 +9,12 @@ what a save sends, when it runs, and what an acknowledgement may change. ## One session per post -One session per opened post, built and disposed together. A new post always gets -its own; nothing is carried from one new post to the next. Once a create -acquires an id the URL is replaced from new to edit as a state-driven effect, -with the screen keyed on the session so the switch does not remount the editor. +One session per opened post, built and disposed together. Building a session +starts no timer, request or outside subscription, so a session discarded without +`dispose()` leaves nothing running. A new post always gets its own; nothing is +carried from one new post to the next. Once a create acquires an id the URL is +replaced from new to edit as a state-driven effect, with the screen keyed on the +session so the switch does not remount the editor. Requests are made without the transport's session-expiry redirect, so an expired session is surfaced in place rather than navigating away from unsaved content. diff --git a/apps/admin/src/editor/session/editor-session.construction.test.ts b/apps/admin/src/editor/session/editor-session.construction.test.ts new file mode 100644 index 00000000000..428e8c639ca --- /dev/null +++ b/apps/admin/src/editor/session/editor-session.construction.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { EditorSessionTransport } from '@/editor/session/editor-session'; +import type { EditorRecord } from '@/editor/session/projection'; +import { + body, + record, + serializedFields, + sessionHarness, +} from '@/editor/session/__test-utils__/session-harness'; + +type SaveEngineModule = typeof import('@/editor/engine/save-engine'); +type SlugMachineModule = typeof import('@/editor/engine/slug-machine'); + +const subscriptions = vi.hoisted(() => ({ engine: 0, slugMachine: 0 })); + +vi.mock('@/editor/engine/save-engine', async (importOriginal) => { + const actual = await importOriginal(); + const createSaveEngine = ((ports: Parameters[0]) => { + const engine = actual.createSaveEngine(ports); + const subscribe: typeof engine.subscribe = (listener) => { + subscriptions.engine += 1; + return engine.subscribe(listener); + }; + return { ...engine, subscribe }; + }) as SaveEngineModule['createSaveEngine']; + return { ...actual, createSaveEngine }; +}); + +vi.mock('@/editor/engine/slug-machine', async (importOriginal) => { + const actual = await importOriginal(); + const createSlugMachine: SlugMachineModule['createSlugMachine'] = (options) => { + const machine = actual.createSlugMachine(options); + const subscribe: typeof machine.subscribe = (listener) => { + subscriptions.slugMachine += 1; + return machine.subscribe(listener); + }; + return { ...machine, subscribe }; + }; + return { ...actual, createSlugMachine }; +}); + +function spyingTransport() { + return { + create: vi.fn((payload) => + Promise.resolve({ + ...record(), + ...serializedFields(payload), + id: 'created-id', + } as EditorRecord), + ), + update: vi.fn((payload) => + Promise.resolve({ + ...record(), + ...serializedFields(payload), + updated_at: '2026-01-01T00:00:01.000Z', + } as EditorRecord), + ), + generateSlug: vi.fn(() => Promise.resolve('hello')), + }; +} + +// React StrictMode runs a `useState` initializer twice and drops the first +// result without disposing it, so construction must start nothing. +describe('createEditorSession construction', () => { + beforeEach(() => { + vi.useFakeTimers(); + subscriptions.engine = 0; + subscriptions.slugMachine = 0; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it.each([ + ['an existing post', { record: record() }], + ['a new post', {}], + ])('starts no IO, timer or outside subscription for %s', async (_, options) => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const transport = spyingTransport(); + const onError = vi.fn(); + const onIdAcquired = vi.fn(); + const autosaveDebounceMs = vi.fn(() => undefined); + + const { session } = sessionHarness({ + ...options, + transport, + onError, + onIdAcquired, + autosaveDebounceMs, + }); + await vi.advanceTimersByTimeAsync(0); + + expect(transport.create).not.toHaveBeenCalled(); + expect(transport.update).not.toHaveBeenCalled(); + expect(transport.generateSlug).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + expect(autosaveDebounceMs).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + expect(onIdAcquired).not.toHaveBeenCalled(); + // The only wiring is internal: the session's view listens to its own slug + // machine, and hears its own engine through the `onStateChange` port. + expect(subscriptions).toEqual({ engine: 0, slugMachine: 1 }); + expect(session.getState()).toEqual({ kind: 'idle' }); + }); + + it('leaves a second session fully working when the first is discarded undisposed', async () => { + const transport = spyingTransport(); + const onError = vi.fn(); + const onIdAcquired = vi.fn(); + const shared = { record: record(), transport, onError, onIdAcquired }; + + sessionHarness({ ...shared, baseline: record().lexical }); + const { session } = sessionHarness({ ...shared, baseline: record().lexical }); + const edited = body('Hello and more'); + + session.patchLexical(edited); + session.dispatchAutosave(); + await vi.advanceTimersByTimeAsync(3000); + + expect(transport.update).toHaveBeenCalledTimes(1); + expect(transport.update.mock.calls[0][0]).toMatchObject({ lexical: JSON.stringify(edited) }); + expect(transport.create).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + expect(session.isDirty()).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/apps/admin/src/editor/session/use-editor-session.ts b/apps/admin/src/editor/session/use-editor-session.ts index 8834f7ae672..b0a263a0830 100644 --- a/apps/admin/src/editor/session/use-editor-session.ts +++ b/apps/admin/src/editor/session/use-editor-session.ts @@ -196,6 +196,8 @@ export function useEditorSession({ autosaveDebounceMs.current = bootedDebounceMs(configData?.config.editorAutosaveDebounceMs); }); + // Construction must start no timer, request or outside subscription: + // StrictMode may call this twice and discard the first session undisposed. const [session] = useState(() => createEditorSession({ record, diff --git a/apps/admin/src/ember-bridge/ember-bridge.test.tsx b/apps/admin/src/ember-bridge/ember-bridge.test.tsx index 7078c743b04..cd8cf7ca013 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.test.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.test.tsx @@ -625,6 +625,25 @@ describe('theme bridge helpers', () => { expect(applyEmberAdminThemePreference('dark')).toBe(false); }); + test('navigateEmberBillingSubRoute hands the sub-route to Ember and reports it', async () => { + const { navigateEmberBillingSubRoute } = await import('./ember-bridge'); + const mock = createMockStateBridge(); + const navigate = vi.fn(); + mock.stateBridge.navigateToBillingSubRoute = navigate; + window.EmberBridge = { state: mock.stateBridge }; + + expect(navigateEmberBillingSubRoute('/plans')).toBe(true); + expect(navigate).toHaveBeenCalledWith('/plans'); + }); + + test('navigateEmberBillingSubRoute returns false without a bridge or method', async () => { + const { navigateEmberBillingSubRoute } = await import('./ember-bridge'); + expect(navigateEmberBillingSubRoute('/plans')).toBe(false); + + window.EmberBridge = { state: createMockStateBridge().stateBridge }; + expect(navigateEmberBillingSubRoute('/plans')).toBe(false); + }); + test('preloadEmberAdminThemeStylesheet resolves with and without the bridge', async () => { const { preloadEmberAdminThemeStylesheet } = await import('./ember-bridge'); await expect(preloadEmberAdminThemeStylesheet()).resolves.toBeUndefined(); diff --git a/apps/admin/src/ember-bridge/ember-bridge.tsx b/apps/admin/src/ember-bridge/ember-bridge.tsx index aa874e16da5..e4fb6fa3ed2 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.tsx @@ -27,6 +27,7 @@ export interface StateBridge { isFeatureEnabled?: (name: string) => boolean | undefined; preloadAdminThemeStylesheet?: () => Promise; applyAdminThemePreference?: (mode: AdminThemeMode) => Promise | void; + navigateToBillingSubRoute?: (subRoute: string) => void; on( event: K, callback: (event: StateBridgeEventMap[K]) => void, @@ -315,6 +316,19 @@ export function applyEmberAdminThemePreference(mode: AdminThemeMode): boolean { return true; } +/** + * Hands a billing app sub-route straight to Ember's billing app, for when the + * billing route is already showing. Returns false when no bridge is present. + */ +export function navigateEmberBillingSubRoute(subRoute: string): boolean { + const stateBridge = window.EmberBridge?.state; + if (!stateBridge?.navigateToBillingSubRoute) { + return false; + } + stateBridge.navigateToBillingSubRoute(subRoute); + return true; +} + /** * React -> Ember handlers for the FrameworkProvider. Feature flag overrides * wait for Ember to load; mutation handlers no-op when the bridge is absent. diff --git a/apps/admin/src/ember-bridge/index.ts b/apps/admin/src/ember-bridge/index.ts index 9a411fd7dde..707fde8177a 100644 --- a/apps/admin/src/ember-bridge/index.ts +++ b/apps/admin/src/ember-bridge/index.ts @@ -15,6 +15,7 @@ export { isEmberThemeManaged, preloadEmberAdminThemeStylesheet, applyEmberAdminThemePreference, + navigateEmberBillingSubRoute, emberMutationHandlers, } from './ember-bridge'; export type { diff --git a/apps/admin/src/global-search/global-search-context.ts b/apps/admin/src/global-search/global-search-context.ts new file mode 100644 index 00000000000..8630ffa6d9a --- /dev/null +++ b/apps/admin/src/global-search/global-search-context.ts @@ -0,0 +1,8 @@ +import { createContext, useContext } from 'react'; + +export const OpenGlobalSearchContext = createContext<(() => void) | null>(null); + +/** Opens the React Cmd-K search, or null while Ember still owns search. */ +export function useOpenGlobalSearch() { + return useContext(OpenGlobalSearchContext); +} diff --git a/apps/admin/src/global-search/global-search-modal.tsx b/apps/admin/src/global-search/global-search-modal.tsx new file mode 100644 index 00000000000..7ea778aaa2e --- /dev/null +++ b/apps/admin/src/global-search/global-search-modal.tsx @@ -0,0 +1,171 @@ +import { Fragment, useState } from 'react'; +import { + Badge, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + Dialog, + DialogContent, + DialogTitle, +} from '@tryghost/shade/components'; +import { Text } from '@tryghost/shade/primitives'; +import { cn } from '@tryghost/shade/utils'; +import { useLocation, useNavigate } from '@tryghost/admin-x-framework'; +import { navigateEmberBillingSubRoute } from '@/ember-bridge'; +import { useEmberOwnedRouteMatcher } from '@/routes'; +import { getSearchDestination } from './search-destination'; +import type { SearchResult } from './searchables'; +import { useGlobalSearch } from './use-global-search'; + +function escapeRegExp(text: string) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Splits `text` around case-insensitive matches of the whole term. */ +function highlightSegments(text: string, term: string) { + const segments: Array<{ start: number; text: string; match: boolean }> = []; + let start = 0; + + for (const found of text.matchAll(new RegExp(escapeRegExp(term), 'gi'))) { + if (found.index > start) { + segments.push({ start, text: text.slice(start, found.index), match: false }); + } + segments.push({ start: found.index, text: found[0], match: true }); + start = found.index + found[0].length; + } + + if (start < text.length) { + segments.push({ start, text: text.slice(start), match: false }); + } + + return segments; +} + +function HighlightedText({ text, term }: { text: string; term: string }) { + if (!term.trim()) { + return text; + } + + return highlightSegments(text, term).map((segment) => + segment.match ? ( + + {segment.text} + + ) : ( + {segment.text} + ), + ); +} + +const STATUS_LABELS: Record = { + draft: { label: 'Draft', tone: 'bg-pink/10 text-pink' }, + scheduled: { label: 'Scheduled', tone: 'bg-green/10 text-green' }, +}; + +function StatusBadge({ status }: { status?: string }) { + const badge = status ? STATUS_LABELS[status] : undefined; + + if (!badge) { + return null; + } + + return {badge.label}; +} + +/** The search itself; rendered inside the dialog so its index queries stop once the dialog closes. */ +function GlobalSearchPanel({ onClose }: { onClose: () => void }) { + const [term, setTerm] = useState(''); + const { results, isLoading } = useGlobalSearch(term); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const isEmberOwned = useEmberOwnedRouteMatcher(); + + const openResult = (result: SearchResult) => { + const destination = getSearchDestination(result); + onClose(); + + if (!destination) { + return; + } + + if ( + destination.billingSubRoute && + pathname === destination.path && + navigateEmberBillingSubRoute(destination.billingSubRoute) + ) { + return; + } + + navigate(destination.path, { crossApp: isEmberOwned(destination.path) }); + }; + + const hasTerm = term.trim() !== ''; + + return ( + <> + + + + {results.map((group, index) => ( + + {index > 0 && } + + {group.options.map((result) => ( + openResult(result)} + > + + + + + + ))} + + + ))} + {hasTerm && results.length === 0 && ( + {isLoading ? 'Loading' : 'No results found'} + )} + + + {results.length === 0 && ( + + Open with Ctrl/⌘ + K + + )} + + ); +} + +interface GlobalSearchModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function GlobalSearchModal({ open, onOpenChange }: GlobalSearchModalProps) { + return ( + + + Search site + onOpenChange(false)} /> + + + ); +} diff --git a/apps/admin/src/global-search/global-search-provider.tsx b/apps/admin/src/global-search/global-search-provider.tsx new file mode 100644 index 00000000000..9d04f0bb450 --- /dev/null +++ b/apps/admin/src/global-search/global-search-provider.tsx @@ -0,0 +1,71 @@ +import { type ReactNode, Suspense, lazy, useCallback, useEffect, useState } from 'react'; +import { useAdminSidebarVisibility } from '@/layout/sidebar-visibility'; +import { useFlagGatedRouteOwner } from '@/use-flag-gated-route-owner'; +import { OpenGlobalSearchContext } from './global-search-context'; +import { isSearchShortcut } from './search-shortcut'; + +// the modal pulls in the search index and FlexSearch, so it loads once search is available +const loadGlobalSearchModal = () => import('./global-search-modal'); +const GlobalSearchModal = lazy(loadGlobalSearchModal); + +// open React dialogs, and Ember's promise and fullscreen modals +const OPEN_DIALOG = + ':is([role="dialog"], [role="alertdialog"])[data-state="open"], .epm-modal, .fullscreen-modal'; + +/** + * Owns the Cmd-K search modal behind the `globalSearchReact` flag: its open + * state, the Cmd/Ctrl+K shortcut, and the lazily loaded modal. + */ +export function GlobalSearchProvider({ children }: { children: ReactNode }) { + // Ember's Labs state decides while Ember is present, so both sides agree on who owns search + const enabled = useFlagGatedRouteOwner('globalSearchReact') === 'react'; + const sidebarVisible = useAdminSidebarVisibility(); + const canSearch = enabled && sidebarVisible; + + // `count` remounts the modal on each open so it starts with an empty term + const [modal, setModal] = useState({ open: false, count: 0 }); + + const openSearch = useCallback(() => { + setModal((current) => (current.open ? current : { open: true, count: current.count + 1 })); + }, []); + + const setOpen = useCallback((open: boolean) => { + setModal((current) => ({ ...current, open })); + }, []); + + useEffect(() => { + if (!canSearch) { + setOpen(false); + return; + } + + void loadGlobalSearchModal(); + + // not skipped when already handled: Ember's own shortcut prevents the default first + const onKeyDown = (event: KeyboardEvent) => { + if (!isSearchShortcut(event)) { + return; + } + // a result picked over another dialog (eg. an unsaved-changes prompt) would redirect it + if (!modal.open && document.querySelector(OPEN_DIALOG)) { + return; + } + event.preventDefault(); + openSearch(); + }; + + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [canSearch, modal.open, openSearch, setOpen]); + + return ( + + {children} + {modal.count > 0 && ( + + + + )} + + ); +} diff --git a/apps/admin/src/global-search/global-search.acceptance.test.tsx b/apps/admin/src/global-search/global-search.acceptance.test.tsx new file mode 100644 index 00000000000..ea3af26cb49 --- /dev/null +++ b/apps/admin/src/global-search/global-search.acceptance.test.tsx @@ -0,0 +1,301 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { userEvent } from 'vitest/browser'; + +import { + allowUnhandledRequests, + configResponse, + currentRoute, + fakeAdminEndpoint, + fakeTags, + renderAdminApp, + type RenderAdminAppOptions, + tag, +} from '@test-utils/acceptance'; + +import type { EmberDataChangeEvent } from '@/ember-bridge'; +import { sidebarScreen } from '@/layout/sidebar.screen'; +import { tagDetailScreen } from '@/tags/detail/tag-detail.screen'; + +import { globalSearchScreen } from './global-search.screen'; + +const flagOn: RenderAdminAppOptions = { labs: { globalSearchReact: true } }; + +const handoff = () => + JSON.parse(document.body.dataset.externalNavigate ?? 'null') as { route: string } | null; + +function fakeSearchIndex() { + return { + posts: fakeAdminEndpoint('GET', '/search-index/posts/', { + posts: [{ id: 'p1', title: 'First post', status: 'draft' }], + }), + pages: fakeAdminEndpoint('GET', '/search-index/pages/', { + pages: [{ id: 'g1', title: 'First page', status: 'published' }], + }), + tags: fakeAdminEndpoint('GET', '/search-index/tags/', { + tags: [{ id: 't1', slug: 'first-tag', name: 'First tag' }], + }), + users: fakeAdminEndpoint('GET', '/search-index/users/', { + users: [{ id: 'u1', slug: 'first-user', name: 'First user' }], + }), + }; +} + +function withBilling(): RenderAdminAppOptions { + const config = configResponse(); + config.config.hostSettings = { + billing: { + enabled: true, + search: { + groupName: 'Acme Hosting', + items: [{ id: 'change-plan', title: 'Change plan', path: '/plans', keywords: 'billing' }], + }, + }, + }; + return { ...flagOn, boot: { browseConfig: { response: config } } }; +} + +/** The Ember half of the state bridge: the billing handoff, and saves Ember reports. */ +function installEmberBridge() { + const navigateToBillingSubRoute = vi.fn(); + const dataChangeHandlers = new Set<(event: EmberDataChangeEvent) => void>(); + const state = { + on: (event: string, callback: (event: EmberDataChangeEvent) => void) => { + if (event === 'emberDataChange') { + dataChangeHandlers.add(callback); + } + }, + off: (_event: string, callback: (event: EmberDataChangeEvent) => void) => { + dataChangeHandlers.delete(callback); + }, + sidebarVisible: true, + getRouteUrl: (routeName: string) => routeName, + isRouteActive: () => false, + navigateToBillingSubRoute, + }; + window.EmberBridge = { state } as unknown as typeof window.EmberBridge; + + return { + navigateToBillingSubRoute, + reportSave: (modelName: string) => { + dataChangeHandlers.forEach((handler) => + handler({ operation: 'update', modelName, id: 'p1', data: null }), + ); + }, + }; +} + +/** The listbox the input's `aria-controls` names, or null when it isn't in the DOM. */ +function controlledListbox() { + const id = globalSearchScreen.input().element().getAttribute('aria-controls'); + return id ? document.getElementById(id) : null; +} + +async function openAndSearch(term: string) { + await globalSearchScreen.openButton().click(); + await globalSearchScreen.search(term); +} + +async function closeWithEscape() { + await userEvent.keyboard('{Escape}'); + await expect.element(globalSearchScreen.dialog()).not.toBeInTheDocument(); +} + +describe('Cmd-K search', () => { + let index: ReturnType; + + beforeEach(() => { + delete document.body.dataset.externalNavigate; + fakeTags([]); + index = fakeSearchIndex(); + }); + + afterEach(() => { + delete window.EmberBridge; + }); + + it('opens from the sidebar button and lists grouped results', async () => { + await renderAdminApp('/tags', flagOn); + await globalSearchScreen.openButton().click(); + await expect.element(globalSearchScreen.input()).toHaveFocus(); + await expect.poll(controlledListbox).not.toBeNull(); + await expect + .element(globalSearchScreen.shortcutHint()) + .toHaveTextContent('Open with Ctrl/⌘ + K'); + + await globalSearchScreen.search('first'); + + await expect.element(globalSearchScreen.group('Staff')).toBeVisible(); + await expect.element(globalSearchScreen.group('Tags')).toBeVisible(); + await expect.element(globalSearchScreen.option(/First post/)).toHaveTextContent('Draft'); + await expect.element(globalSearchScreen.option(/First page/)).toBeVisible(); + await expect.element(globalSearchScreen.highlight(/First post/)).toHaveTextContent('First'); + await expect.element(globalSearchScreen.shortcutHint()).not.toBeInTheDocument(); + }); + + it('opens from the shortcut after Ember has already handled the key', async () => { + // Ember's keymaster binding runs first and prevents the default + const emberShortcut = (event: KeyboardEvent) => event.preventDefault(); + document.addEventListener('keydown', emberShortcut); + + try { + await renderAdminApp('/tags', flagOn); + await expect.element(globalSearchScreen.openButton()).toBeVisible(); + + await globalSearchScreen.pressShortcut(); + + await expect.element(globalSearchScreen.input()).toHaveFocus(); + await closeWithEscape(); + } finally { + document.removeEventListener('keydown', emberShortcut); + } + }); + + it('closes on a click over the shortcut hint, which sits on the overlay', async () => { + await renderAdminApp('/tags', flagOn); + await globalSearchScreen.openButton().click(); + await expect.element(globalSearchScreen.shortcutHint()).toBeVisible(); + + await globalSearchScreen.clickAt(globalSearchScreen.shortcutHint()); + + await expect.element(globalSearchScreen.dialog()).not.toBeInTheDocument(); + }); + + it('leaves the index alone while closed, then reloads what changed on the next search', async () => { + const ember = installEmberBridge(); + await renderAdminApp('/tags', flagOn); + await openAndSearch('first'); + await expect.element(globalSearchScreen.option(/First post/)).toBeVisible(); + await closeWithEscape(); + + ember.reportSave('post'); + // no request can follow a save while search is closed; allow time for one to start + await new Promise((resolve) => { + setTimeout(resolve, 300); + }); + expect(index.posts.requests).toHaveLength(1); + + await openAndSearch('first'); + await expect.element(globalSearchScreen.option(/First post/)).toBeVisible(); + expect(index.posts.requests).toHaveLength(2); + }); + + it('starts empty each time it opens', async () => { + await renderAdminApp('/tags', flagOn); + await openAndSearch('first'); + await expect.element(globalSearchScreen.option(/First tag/)).toBeVisible(); + await closeWithEscape(); + + await globalSearchScreen.openButton().click(); + + await expect.element(globalSearchScreen.input()).toHaveValue(''); + }); + + it('says when nothing matches', async () => { + await renderAdminApp('/tags', flagOn); + + await openAndSearch('nothing like this'); + + await expect.element(globalSearchScreen.noResults()).toBeVisible(); + }); + + it('opens a tag in the React tag screen', async () => { + fakeAdminEndpoint('GET', /^\/tags\/slug\/first-tag\//, { + tags: [tag({ name: 'First tag', slug: 'first-tag' })], + }); + await renderAdminApp('/tags', flagOn); + await openAndSearch('first tag'); + + await globalSearchScreen.option(/First tag/).click(); + + await expect.poll(currentRoute).toBe('/tags/first-tag'); + await expect.element(globalSearchScreen.dialog()).not.toBeInTheDocument(); + }); + + it('hands a post to the Ember editor', async () => { + await renderAdminApp('/tags', flagOn); + await openAndSearch('first post'); + await expect.element(globalSearchScreen.option(/First post/)).toBeVisible(); + + await userEvent.keyboard('{Enter}'); + + await expect.poll(() => handoff()?.route).toBe('/editor/post/p1'); + }); + + it('opens a post in the React editor when React serves it', async () => { + // the editor owns its request graph + allowUnhandledRequests(); + await renderAdminApp('/tags', { labs: { globalSearchReact: true, editorReact: true } }); + await openAndSearch('first post'); + + await globalSearchScreen.option(/First post/).click(); + + await expect.poll(currentRoute).toBe('/editor/post/p1'); + expect(handoff()).toBeNull(); + }); + + it('hands a billing result to the billing app route', async () => { + await renderAdminApp('/tags', withBilling()); + await openAndSearch('plan'); + + await globalSearchScreen.option(/Change plan/).click(); + + await expect.poll(() => handoff()?.route).toBe('/pro/plans'); + }); + + it('sends a billing result for the billing route on screen straight to the billing app', async () => { + const { navigateToBillingSubRoute } = installEmberBridge(); + await renderAdminApp('/pro/plans', withBilling()); + await openAndSearch('plan'); + + await globalSearchScreen.option(/Change plan/).click(); + + await expect.poll(() => navigateToBillingSubRoute.mock.calls).toEqual([['/plans']]); + expect(handoff()).toBeNull(); + }); + + it('leaves the shortcut alone while another dialog is open', async () => { + const firstTag = tag({ name: 'First tag', slug: 'first-tag' }); + fakeTags([firstTag]); + fakeAdminEndpoint('GET', /^\/tags\/slug\/first-tag\//, { tags: [firstTag] }); + await renderAdminApp('/tags/first-tag', flagOn); + await expect.element(globalSearchScreen.openButton()).toBeVisible(); + expect(globalSearchScreen.dispatchShortcut()).toBe(true); + await closeWithEscape(); + + await tagDetailScreen.actionsButton().click(); + await tagDetailScreen.deleteTagMenuItem().click(); + await expect.element(tagDetailScreen.deleteModal()).toBeVisible(); + + expect(globalSearchScreen.dispatchShortcut()).toBe(false); + await expect.element(globalSearchScreen.dialog()).not.toBeInTheDocument(); + }); + + it('closes and ignores the shortcut once the sidebar is hidden', async () => { + await renderAdminApp('/tags', flagOn); + await expect.element(globalSearchScreen.openButton()).toBeVisible(); + expect(globalSearchScreen.dispatchShortcut()).toBe(true); + await expect.element(globalSearchScreen.dialog()).toBeVisible(); + + window.location.hash = '#/editor/post/p1'; + + await expect.element(sidebarScreen.shellNav()).not.toBeInTheDocument(); + await expect.element(globalSearchScreen.dialog()).not.toBeInTheDocument(); + expect(globalSearchScreen.dispatchShortcut()).toBe(false); + }); + + it('leaves search to Ember without the flag', async () => { + const emberKeypresses: KeyboardEvent[] = []; + const recordKeypress = (event: KeyboardEvent) => emberKeypresses.push(event); + document.addEventListener('keydown', recordKeypress); + + try { + await renderAdminApp('/tags'); + await globalSearchScreen.openButton().click(); + + expect(emberKeypresses.map((event) => event.keyCode)).toEqual([75]); + expect(globalSearchScreen.dispatchShortcut()).toBe(false); + } finally { + document.removeEventListener('keydown', recordKeypress); + } + }); +}); diff --git a/apps/admin/src/global-search/global-search.screen.ts b/apps/admin/src/global-search/global-search.screen.ts new file mode 100644 index 00000000000..4cea05e392c --- /dev/null +++ b/apps/admin/src/global-search/global-search.screen.ts @@ -0,0 +1,56 @@ +import { type Locator, page, userEvent } from 'vitest/browser'; +import { + noResultsText, + searchDialog, + searchSiteButton, + shortcutHintText, +} from '@tryghost/test-data/selectors/global-search'; + +import { isMacPlatform } from '@/utils/is-mac-platform'; + +const modifier = isMacPlatform() ? 'Meta' : 'Control'; + +/** Cmd-K search locators and gestures for acceptance specs; no assertions. */ +export const globalSearchScreen = { + openButton: () => page.getByRole('button', { name: searchSiteButton }), + dialog: () => page.getByRole('dialog', { name: searchDialog }), + input: () => globalSearchScreen.dialog().getByRole('combobox'), + group: (name: string) => globalSearchScreen.dialog().getByRole('group', { name }), + option: (name: string | RegExp) => globalSearchScreen.dialog().getByRole('option', { name }), + noResults: () => globalSearchScreen.dialog().getByText(noResultsText), + shortcutHint: () => globalSearchScreen.dialog().getByText(shortcutHintText), + highlight: (option: string | RegExp) => globalSearchScreen.option(option).getByRole('mark'), + + async pressShortcut(): Promise { + await userEvent.keyboard(`{${modifier}>}k{/${modifier}}`); + }, + + /** + * Dispatches the shortcut on the document and reports whether anything + * handled it, which is observable without waiting for the modal to load. + */ + dispatchShortcut(): boolean { + const event = new KeyboardEvent('keydown', { + key: 'k', + code: 'KeyK', + metaKey: modifier === 'Meta', + ctrlKey: modifier === 'Control', + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(event); + return event.defaultPrevented; + }, + + /** Clicks the page at the centre of `locator`'s box, landing on whatever is on top there. */ + async clickAt(locator: Locator): Promise { + const box = locator.element().getBoundingClientRect(); + await userEvent.click(page.elementLocator(document.body), { + position: { x: box.x + box.width / 2, y: box.y + box.height / 2 }, + }); + }, + + async search(term: string): Promise { + await globalSearchScreen.input().fill(term); + }, +}; diff --git a/apps/admin/src/global-search/search-destination.test.ts b/apps/admin/src/global-search/search-destination.test.ts new file mode 100644 index 00000000000..cd531c912e0 --- /dev/null +++ b/apps/admin/src/global-search/search-destination.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { getSearchDestination } from './search-destination'; +import type { SearchResult } from './searchables'; + +const result = (overrides: Partial): SearchResult => ({ + id: 'post.p1', + title: 'Title', + groupName: 'Posts', + ...overrides, +}); + +describe('getSearchDestination', () => { + it.each([ + ['post.p1', '/editor/post/p1'], + ['page.g1', '/editor/page/g1'], + ['user.jamie-larson', '/settings/staff/jamie-larson'], + ['tag.news', '/tags/news'], + ])('sends %s to %s', (id, path) => { + expect(getSearchDestination(result({ id }))).toEqual({ path }); + }); + + it('routes by model, not by the group name a host may configure', () => { + expect(getSearchDestination(result({ id: 'tag.posts', groupName: 'Posts' }))).toEqual({ + path: '/tags/posts', + }); + }); + + it('sends billing results to their billing app route', () => { + expect( + getSearchDestination( + result({ id: 'pro-page.plans', groupName: 'Posts', groupKey: 'billing', path: '/plans' }), + ), + ).toEqual({ path: '/pro/plans', billingSubRoute: '/plans' }); + }); + + it('sends the billing app root to the billing route itself', () => { + expect( + getSearchDestination(result({ id: 'pro-page.home', groupKey: 'billing', path: '/' })), + ).toEqual({ path: '/pro', billingSubRoute: '/' }); + }); + + it('has no destination for an unknown model', () => { + expect(getSearchDestination(result({ id: 'member.m1' }))).toBeNull(); + }); +}); diff --git a/apps/admin/src/global-search/search-destination.ts b/apps/admin/src/global-search/search-destination.ts new file mode 100644 index 00000000000..c58bbc3a570 --- /dev/null +++ b/apps/admin/src/global-search/search-destination.ts @@ -0,0 +1,37 @@ +import { BILLING_SEARCH_GROUP_KEY, type SearchResult } from './searchables'; + +const BILLING_ROUTE_ROOT = '/pro'; + +export interface SearchDestination { + path: string; + /** Set for billing results: the billing app route the path shows. */ + billingSubRoute?: string; +} + +/** Where selecting a search result goes, keyed on its model rather than its group's display name. */ +export function getSearchDestination(result: SearchResult): SearchDestination | null { + if (result.groupKey === BILLING_SEARCH_GROUP_KEY) { + const subRoute = result.path ?? '/'; + return { + path: subRoute === '/' ? BILLING_ROUTE_ROOT : `${BILLING_ROUTE_ROOT}${subRoute}`, + billingSubRoute: subRoute, + }; + } + + const separator = result.id.indexOf('.'); + const model = result.id.slice(0, separator); + const key = encodeURIComponent(result.id.slice(separator + 1)); + + switch (model) { + case 'post': + return { path: `/editor/post/${key}` }; + case 'page': + return { path: `/editor/page/${key}` }; + case 'user': + return { path: `/settings/staff/${key}` }; + case 'tag': + return { path: `/tags/${key}` }; + default: + return null; + } +} diff --git a/apps/admin/src/global-search/search-shortcut.test.ts b/apps/admin/src/global-search/search-shortcut.test.ts new file mode 100644 index 00000000000..73fecd12cdf --- /dev/null +++ b/apps/admin/src/global-search/search-shortcut.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { isSearchShortcut } from './search-shortcut'; + +const keydown = (init: KeyboardEventInit) => new KeyboardEvent('keydown', { key: 'k', ...init }); + +describe('isSearchShortcut', () => { + it('matches Cmd+K on a Mac and Ctrl+K elsewhere', () => { + expect(isSearchShortcut(keydown({ metaKey: true }), true)).toBe(true); + expect(isSearchShortcut(keydown({ ctrlKey: true }), false)).toBe(true); + }); + + it("ignores the other platform's modifier", () => { + expect(isSearchShortcut(keydown({ ctrlKey: true }), true)).toBe(false); + expect(isSearchShortcut(keydown({ metaKey: true }), false)).toBe(false); + }); + + it.each([ + ['shift', { shiftKey: true }], + ['alt', { altKey: true }], + ['both command keys', { ctrlKey: true }], + ])('ignores an extra %s modifier', (_name, extra) => { + expect(isSearchShortcut(keydown({ metaKey: true, ...extra }), true)).toBe(false); + }); + + it('ignores other keys and a bare K', () => { + expect(isSearchShortcut(keydown({ key: 'j', metaKey: true }), true)).toBe(false); + expect(isSearchShortcut(keydown({}), true)).toBe(false); + }); + + it('matches the K key on layouts where it types a non-Latin character', () => { + expect(isSearchShortcut(keydown({ key: 'л', code: 'KeyK', metaKey: true }), true)).toBe(true); + }); + + it('follows the typed letter on Latin layouts that move K', () => { + expect(isSearchShortcut(keydown({ key: 'e', code: 'KeyK', metaKey: true }), true)).toBe(false); + expect(isSearchShortcut(keydown({ key: 'k', code: 'KeyN', metaKey: true }), true)).toBe(true); + }); + + it('ignores keydown events without a key, as autofill sends', () => { + const autofill = new Event('keydown') as KeyboardEvent; + + expect(isSearchShortcut(autofill, true)).toBe(false); + expect( + isSearchShortcut( + Object.assign(new Event('keydown'), { metaKey: true }) as KeyboardEvent, + true, + ), + ).toBe(false); + }); + + it('ignores keys pressed while composing text', () => { + expect(isSearchShortcut(keydown({ metaKey: true, isComposing: true }), true)).toBe(false); + }); +}); diff --git a/apps/admin/src/global-search/search-shortcut.ts b/apps/admin/src/global-search/search-shortcut.ts new file mode 100644 index 00000000000..08a47059b13 --- /dev/null +++ b/apps/admin/src/global-search/search-shortcut.ts @@ -0,0 +1,18 @@ +import { isMacPlatform } from '@/utils/is-mac-platform'; + +export const searchShortcutLabel = isMacPlatform() ? '⌘K' : 'Ctrl+K'; + +/** Cmd+K on a Mac, Ctrl+K elsewhere, with no other modifier. */ +export function isSearchShortcut(event: KeyboardEvent, isMac: boolean = isMacPlatform()): boolean { + const modifier = isMac ? event.metaKey : event.ctrlKey; + const otherModifier = isMac ? event.ctrlKey : event.metaKey; + + if (!modifier || otherModifier || event.altKey || event.shiftKey || event.isComposing) { + return false; + } + + // autofill sends keydown events without a key + const key = typeof event.key === 'string' ? event.key.toLowerCase() : ''; + // the physical K key counts only when its layout types a non-Latin character + return key === 'k' || (!/^[a-z]$/.test(key) && event.code === 'KeyK'); +} diff --git a/apps/admin/src/layout/admin-layout.tsx b/apps/admin/src/layout/admin-layout.tsx index b44ebf11ea4..79e6a68df43 100644 --- a/apps/admin/src/layout/admin-layout.tsx +++ b/apps/admin/src/layout/admin-layout.tsx @@ -9,6 +9,7 @@ import AppSidebar from './app-sidebar'; import { MobileNavBar } from './app-sidebar/mobile-nav-bar'; import { ContributorUserMenu } from './app-sidebar/user-menu'; import { DunningBanner, DunningOverlay, useDunningLockTakeover } from '@/dunning'; +import { GlobalSearchProvider } from '@/global-search/global-search-provider'; const networkPageChrome = { contentClassName: 'max-w-(--content-width)', @@ -106,41 +107,43 @@ export function AdminLayout({ children }: AdminLayoutProps) { } return ( - main]:min-w-0', - )} - open={!!currentUser && sidebarVisible} - style={sidebarVisible ? ({ '--sidebar-width': '316px' } as React.CSSProperties) : undefined} - > - {sidebarVisible && ( - - )} - + main]:min-w-0', )} + open={!!currentUser && sidebarVisible} + style={sidebarVisible ? ({ '--sidebar-width': '316px' } as React.CSSProperties) : undefined} > - -
- - {children} - -
- {/* The mobile nav sits outside the takeover's cover (fixed, above the + {sidebarVisible && ( + + )} + + +
+ + {children} + +
+ {/* The mobile nav sits outside the takeover's cover (fixed, above the inset) and its sheet opens in a portal, so it unmounts entirely rather than relying on inert */} - {!dunningLocked && } - -
-
+ {!dunningLocked && } + +
+
+ ); } diff --git a/apps/admin/src/layout/app-sidebar/app-sidebar-header.tsx b/apps/admin/src/layout/app-sidebar/app-sidebar-header.tsx index 299b923f200..e35793a5be4 100644 --- a/apps/admin/src/layout/app-sidebar/app-sidebar-header.tsx +++ b/apps/admin/src/layout/app-sidebar/app-sidebar-header.tsx @@ -6,12 +6,13 @@ import { useBrowseSite } from '@tryghost/admin-x-framework/api/site'; import { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user'; import { isContributorUser } from '@tryghost/admin-x-framework/api/users'; import { isMacPlatform } from '@/utils/is-mac-platform'; +import { useOpenGlobalSearch } from '@/global-search/global-search-context'; +import { searchShortcutLabel } from '@/global-search/search-shortcut'; const ctrlOrCmd = isMacPlatform() ? 'command' : 'ctrl'; -const searchShortcut = ctrlOrCmd === 'command' ? '⌘K' : 'Ctrl+K'; -// Search is currently handled by the Ember app, firing a keyboard event avoids needing to sync state -const openSearchModal = (event: React.MouseEvent) => { +// Without the globalSearchReact flag, search is handled by the Ember app, and firing a keyboard event avoids needing to sync state +const openEmberSearchModal = (event: React.MouseEvent) => { event.preventDefault(); const searchShortcutEvent = new KeyboardEvent('keydown', { key: 'k', @@ -30,6 +31,7 @@ function AppSidebarHeader({ ...props }: React.ComponentProps(settings.data?.settings, 'is_private') ?? false; const showSearch = currentUser && !isContributorUser(currentUser); + const openGlobalSearch = useOpenGlobalSearch(); return ( @@ -61,7 +63,7 @@ function AppSidebarHeader({ ...props }: React.ComponentProps
@@ -71,7 +73,7 @@ function AppSidebarHeader({ ...props }: React.ComponentProps - {searchShortcut} + {searchShortcutLabel} )} diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index 4b8b6872598..d9ab93542d8 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { type AdminRouteHandle, type RouteObject, @@ -227,22 +228,33 @@ export const routes: RouteObject[] = [ // (and so gets router history state, which the unsaved-changes blockers need). const EMBER_ROUTE_COMPONENTS = new Set([EmberFallback, EmberListWithGiftLinks]); -export function useIsEmberOwnedRoute(pathname: string): boolean { +/** Decides for any path whether Ember owns it, for destinations only known at event time. */ +export function useEmberOwnedRouteMatcher(): (pathname: string) => boolean { const postsListOwner = useFlagGatedRouteOwner('postsListReact'); const editorOwner = useFlagGatedRouteOwner('editorReact'); const memberActivityOwner = useFlagGatedRouteOwner('membersActivityReact'); - const leaf = matchRoutes(routes, pathname)?.at(-1)?.route; - if (!leaf) { - return true; - } - if (leaf.Component === PostsListGate || leaf.Component === PagesListGate) { - return postsListOwner !== 'react'; - } - if (leaf.Component === EditorGate) { - return editorOwner !== 'react'; - } - if (leaf.Component === MemberActivityGate) { - return memberActivityOwner !== 'react'; - } - return EMBER_ROUTE_COMPONENTS.has(leaf.Component); + + return useCallback( + (pathname: string) => { + const leaf = matchRoutes(routes, pathname)?.at(-1)?.route; + if (!leaf) { + return true; + } + if (leaf.Component === PostsListGate || leaf.Component === PagesListGate) { + return postsListOwner !== 'react'; + } + if (leaf.Component === EditorGate) { + return editorOwner !== 'react'; + } + if (leaf.Component === MemberActivityGate) { + return memberActivityOwner !== 'react'; + } + return EMBER_ROUTE_COMPONENTS.has(leaf.Component); + }, + [postsListOwner, editorOwner, memberActivityOwner], + ); +} + +export function useIsEmberOwnedRoute(pathname: string): boolean { + return useEmberOwnedRouteMatcher()(pathname); } diff --git a/apps/ember-admin/app/components/aspect-ratio-box.hbs b/apps/ember-admin/app/components/aspect-ratio-box.hbs deleted file mode 100644 index 3eb667ec10d..00000000000 --- a/apps/ember-admin/app/components/aspect-ratio-box.hbs +++ /dev/null @@ -1,3 +0,0 @@ -{{#unless this.isResizing}} -{{yield}} -{{/unless}} \ No newline at end of file diff --git a/apps/ember-admin/app/components/aspect-ratio-box.js b/apps/ember-admin/app/components/aspect-ratio-box.js deleted file mode 100644 index dfd6ab00753..00000000000 --- a/apps/ember-admin/app/components/aspect-ratio-box.js +++ /dev/null @@ -1,53 +0,0 @@ -import Component from '@ember/component'; -import classic from 'ember-classic-decorator'; -import {assert} from '@ember/debug'; -import {debounce, run} from '@ember/runloop'; - -@classic -export default class AspectRatioBox extends Component { - ratio = '1/1'; - base = 'height'; - isResizing = true; - _ratio = 1; - - init() { - super.init(...arguments); - this._onResizeHandler = () => { - debounce(this, this._resize, 200); - }; - } - - didReceiveAttrs() { - super.didReceiveAttrs(...arguments); - assert( - '{{aspect-ratio-box}} requires a `ratio` property in the format `"16/9"`', - this.ratio.match(/\d+\/\d+/) - ); - this._ratio = this.ratio.split('/').reduce((prev, curr) => prev / curr); - } - - didInsertElement() { - super.didInsertElement(...arguments); - this._resize(); - window.addEventListener('resize', this._onResizeHandler); - } - - willDestroyElement() { - super.willDestroyElement(...arguments); - window.removeEventListener('resize', this._onResizeHandler); - } - - _resize() { - this.set('isResizing', true); - - run.schedule('afterRender', this, function () { - if (this.base === 'height') { - this.element.style.width = `${this.element.clientHeight * this._ratio}px`; - } else { - this.element.style.height = `${this.element.clientWidth * this._ratio}px`; - } - - this.set('isResizing', false); - }); - } -} diff --git a/apps/ember-admin/app/components/gh-blog-url.hbs b/apps/ember-admin/app/components/gh-blog-url.hbs deleted file mode 100644 index 7ea2b33efeb..00000000000 --- a/apps/ember-admin/app/components/gh-blog-url.hbs +++ /dev/null @@ -1 +0,0 @@ -{{{this.config.blogUrl}}} \ No newline at end of file diff --git a/apps/ember-admin/app/components/gh-blog-url.js b/apps/ember-admin/app/components/gh-blog-url.js deleted file mode 100644 index f42cffbe0bf..00000000000 --- a/apps/ember-admin/app/components/gh-blog-url.js +++ /dev/null @@ -1,10 +0,0 @@ -import Component from '@ember/component'; -import classic from 'ember-classic-decorator'; -import {inject} from 'ghost-admin/decorators/inject'; -import {tagName} from '@ember-decorators/component'; - -@classic -@tagName('') -export default class GhBlogUrl extends Component { - @inject config; -} diff --git a/apps/ember-admin/app/components/gh-browser-preview.hbs b/apps/ember-admin/app/components/gh-browser-preview.hbs deleted file mode 100644 index e0b480a90cb..00000000000 --- a/apps/ember-admin/app/components/gh-browser-preview.hbs +++ /dev/null @@ -1,23 +0,0 @@ -{{!-- changes only classes between desktop/mobile to avoid re-rendering yielded contents --}} -
- {{#unless @isMobilePreview}} -
-
-
-
- {{#if (or @icon this.settings.icon)}} - icon - {{else}} - {{svg-jar "default-favicon"}} - {{/if}} - {{or @title this.settings.title}} -
-
-
- {{/unless}} -
-
- {{yield}} -
-
-
\ No newline at end of file diff --git a/apps/ember-admin/app/components/gh-browser-preview.js b/apps/ember-admin/app/components/gh-browser-preview.js deleted file mode 100644 index 8c30f8a48ec..00000000000 --- a/apps/ember-admin/app/components/gh-browser-preview.js +++ /dev/null @@ -1,6 +0,0 @@ -import Component from '@glimmer/component'; -import {inject as service} from '@ember/service'; - -export default class GhBrowserPreview extends Component { - @service settings; -} diff --git a/apps/ember-admin/app/components/gh-date-picker.hbs b/apps/ember-admin/app/components/gh-date-picker.hbs deleted file mode 100644 index 62d37dce014..00000000000 --- a/apps/ember-admin/app/components/gh-date-picker.hbs +++ /dev/null @@ -1,51 +0,0 @@ - -
- - -
- - {{svg-jar "calendar" class="gh-date-picker-cal-icon"}} -
-
- - {{#if (has-block)}} - {{yield (assign - dp - (hash - select=this.onDateSelected - Triger=null - Content=null - Nav=dp.Nav - Days=(component dp.Days minDate=this.minDate maxDate=this.maxDate weekdayFormat="min") - ) - )}} - {{else}} - - - {{/if}} - -
- - {{#if this.error}} -
{{this.error}}
- {{/if}} -
\ No newline at end of file diff --git a/apps/ember-admin/app/components/gh-date-picker.js b/apps/ember-admin/app/components/gh-date-picker.js deleted file mode 100644 index 75e054ad5d6..00000000000 --- a/apps/ember-admin/app/components/gh-date-picker.js +++ /dev/null @@ -1,146 +0,0 @@ -import Component from '@glimmer/component'; -import moment from 'moment-timezone'; -import {action} from '@ember/object'; -import {isBlank} from '@ember/utils'; -import {tracked} from '@glimmer/tracking'; - -export class DateError extends Error { - constructor(msgOrObj) { - if (typeof msgOrObj === 'string') { - super(msgOrObj); - } else { - super(msgOrObj.message); - Object.keys(msgOrObj).forEach((key) => { - if (key !== 'message') { - this[key] = msgOrObj[key]; - } - }); - } - } -} - -export default class GhDatePicker extends Component { - @tracked error = null; - - get dateFormat() { - return 'YYYY-MM-DD'; - } - - get minDate() { - return this._minMaxMoment(this.args.minDate); - } - - get maxDate() { - return this._minMaxMoment(this.args.maxDate); - } - - @action - setDate(dateStr) { - this.error = null; - - if (!dateStr.match(/^\d\d\d\d-\d\d-\d\d$/)) { - this.error = `Date must be ${this.dateFormat}`; - this.args.onError?.(new DateError({ - message: this.error, - date: dateStr - })); - return false; - } - - const mDate = moment(dateStr); - - if (!mDate.isValid()) { - this.error = 'Invalid date'; - this.args.onError?.(new DateError({ - message: this.error, - date: dateStr - })); - return false; - } - - if (this.args.minDate && mDate.isBefore(moment(this.args.minDate))) { - this.error = this.args.minDateError || `Must be on or after ${moment(this.args.minDate).format(this.dateFormat)}`; - - this.args.onError?.(new DateError({ - message: this.error, - date: dateStr - })); - return false; - } - - if (this.args.maxDate && mDate.isAfter(moment(this.args.maxDate))) { - this.error = this.args.maxDateError || `Must be on or before ${moment(this.args.maxDate).format(this.dateFormat)}`; - this.args.onError?.(new DateError({ - message: this.error, - date: dateStr - })); - return false; - } - - this.args.onChange?.(mDate.toDate()); - } - - @action - onDateSelected(datepickerEvent) { - if (datepickerEvent instanceof moment) { - this.setDate(datepickerEvent.format(this.dateFormat)); - } else { - this.setDate(datepickerEvent.id); - } - } - - @action - onDateInput(datepicker, event) { - const skipFocus = true; - datepicker.actions.close(event, skipFocus); - - this.args.onInput?.(event); - } - - @action - onDateBlur(event) { - const value = event.target.value; - - if (!value) { - this.resetInputValue(event.target); - } else { - this.setDate(value); - } - - this.args.onBlur?.(event); - } - - @action - onDateKeydown(datepicker, event) { - if (event.key === 'Escape') { - event.preventDefault(); - event.stopImmediatePropagation(); - this.resetInputValue(event.target); - } - - if (event.key === 'Enter') { - event.preventDefault(); - event.stopImmediatePropagation(); - this.setDate(event.target.value); - datepicker.actions.close(); - } - - this.args.onKeydown?.(event); - } - - @action - resetInputValue(input) { - input.value = moment(this.args.value).format(this.dateFormat); - this.error = null; - } - - _minMaxMoment(date) { - if (date === 'now') { - return moment(moment().format(this.dateFormat)); - } else if (!isBlank(date)) { - return moment(moment(date).format(this.dateFormat)); - } else { - return null; - } - } -} diff --git a/apps/ember-admin/app/components/gh-facebook-url-input.hbs b/apps/ember-admin/app/components/gh-facebook-url-input.hbs deleted file mode 100644 index 1765c5298df..00000000000 --- a/apps/ember-admin/app/components/gh-facebook-url-input.hbs +++ /dev/null @@ -1,10 +0,0 @@ - diff --git a/apps/ember-admin/app/components/gh-facebook-url-input.js b/apps/ember-admin/app/components/gh-facebook-url-input.js deleted file mode 100644 index 4e8e5083ed8..00000000000 --- a/apps/ember-admin/app/components/gh-facebook-url-input.js +++ /dev/null @@ -1,65 +0,0 @@ -import Component from '@glimmer/component'; -import validator from 'validator'; -import {action} from '@ember/object'; - -export default class GhFacebookUrlInput extends Component { - get value() { - const {model, modelProperty, scratchValue} = this.args; - return scratchValue || model[modelProperty]; - } - - @action - setScratchValue(event) { - this.args.setScratchValue?.(event.target.value); - } - - @action - setFacebookUrl(event) { - const {model, modelProperty} = this.args; - - let newUrl = event.target.value; - - // reset errors and validation - model.errors.remove('facebook'); - model.hasValidated.removeObject('facebook'); - - if (!newUrl) { - // Clear out the Facebook url - model[modelProperty] = null; - this.args.setScratchValue?.(null); - return; - } - - try { - // strip any facebook URLs out - newUrl = newUrl.replace(/(https?:\/\/)?(www\.)?facebook\.com/i, ''); - - // don't allow any non-facebook urls - if (newUrl.match(/^(http|\/\/)/i)) { - throw 'invalid url'; - } - - // strip leading / if we have one then concat to full facebook URL - newUrl = newUrl.replace(/^\//, ''); - newUrl = `https://www.facebook.com/${newUrl}`; - - // don't allow URL if it's not valid - if (!validator.isURL(newUrl)) { - throw 'invalid url'; - } - - model[modelProperty] = newUrl; - this.args.setScratchValue?.(null); - } catch (e) { - if (e === 'invalid url') { - const message = 'The URL must be in a format like https://www.facebook.com/yourPage'; - model.errors.add('facebook', message); - return; - } - - throw e; - } finally { - model.hasValidated.pushObject('facebook'); - } - } -} diff --git a/apps/ember-admin/app/components/gh-html-iframe.hbs b/apps/ember-admin/app/components/gh-html-iframe.hbs deleted file mode 100644 index 9a56dc75621..00000000000 --- a/apps/ember-admin/app/components/gh-html-iframe.hbs +++ /dev/null @@ -1,8 +0,0 @@ -
- - -
\ No newline at end of file diff --git a/apps/ember-admin/app/components/gh-html-iframe.js b/apps/ember-admin/app/components/gh-html-iframe.js deleted file mode 100644 index dce995497b1..00000000000 --- a/apps/ember-admin/app/components/gh-html-iframe.js +++ /dev/null @@ -1,81 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; - -export default class GhHtmlIframeComponent extends Component { - iframes = []; - renderedIframe = 0; - toRenderIframe = 1; - - get hiddenIframeStyle() { - return 'position: absolute; visibility: hidden; border: none;'; - } - - get visibleIframeStyle() { - return 'border: none;'; - } - - @action - replaceIframeContents() { - const iframe = this.iframes[this.toRenderIframe]; - - if (iframe && this.args.html) { - iframe.contentWindow.document.open(); - iframe.contentWindow.document.write(this.args.html); - iframe.contentWindow.document.close(); - - // force swap of iframes after a set timeout to account for slower connections - // so we display _something_ in the iframe even though it's still loading - this.swapTimeout = setTimeout(() => { - this.swapIframes(iframe); - }, 500); - } - } - - @action - registerIframe(iframe) { - this.iframes.push(iframe); - - if (this.iframes.indexOf(iframe) === 0) { - iframe.style = this.visibleIframeStyle; - this.replaceIframeContents(); - } - - if (this.iframes.indexOf(iframe) === 1) { - iframe.style = this.hiddenIframeStyle; - } - } - - @action - didLoad(event) { - if (this.isDestroyed || this.isDestroying) { - return; - } - - this.swapIframes(event.target); - } - - swapIframes(renderedIframe) { - if (this.isDestroyed || this.isDestroying) { - return; - } - - window.clearTimeout(this.swapTimeout); - - if (this.iframes.indexOf(renderedIframe) !== this.renderedIframe) { - let newScrollTop = this.iframes[this.renderedIframe].contentDocument.body.scrollTop; - - if (this._lastPageId !== this.args.pageId) { - newScrollTop = 0; - } - this._lastPageId = this.args.pageId; - - if (this.iframes[this.toRenderIframe].contentDocument.body) { - this.iframes[this.toRenderIframe].contentDocument.body.scrollTop = newScrollTop; - } - this.iframes[this.toRenderIframe].style = this.visibleIframeStyle; - this.renderedIframe = this.toRenderIframe; - this.toRenderIframe = this.toRenderIframe === 0 ? 1 : 0; - this.iframes[this.toRenderIframe].style = this.hiddenIframeStyle; - } - } -} diff --git a/apps/ember-admin/app/components/gh-member-details.hbs b/apps/ember-admin/app/components/gh-member-details.hbs deleted file mode 100644 index 65e05001de7..00000000000 --- a/apps/ember-admin/app/components/gh-member-details.hbs +++ /dev/null @@ -1,123 +0,0 @@ -
-
-
- {{#if (or @member.name @member.email)}} - - {{else}} -
- N -
- {{/if}} -
-

- {{or @member.name @member.email}} - {{#unless (or @member.name @member.email)}} - {{#if @member.isNew}} - New member - {{/if}} - {{/unless}} -

-

- {{#if (and @member.name @member.email)}} - {{@member.email}} - {{/if}} -

-
-
- {{#unless @member.isNew}} -
-

- {{svg-jar "pin"}} - {{#if @member.geolocation}} - {{#if (and (eq @member.geolocation.country_code "US") @member.geolocation.region)}} - {{@member.geolocation.region}}, US - {{else}} - {{or @member.geolocation.country "Unknown location"}} - {{/if}} - {{else}} - Unknown location - {{/if}} -

-

- {{svg-jar "eye"}} - {{#if (not (is-empty @member.lastSeenAtUTC))}} - Last seen on {{moment-format (moment-site-tz @member.lastSeenAtUTC) "D MMM YYYY"}} - {{else}} - Not seen yet - {{/if}} -

- {{#if (eq @member.canComment false)}} -

- {{svg-jar "comment-disabled"}} - Comments disabled - -  —  - -

- {{/if}} -
-
-

Signup info

-

- {{svg-jar "member-add"}} - Created — {{moment-format (moment-site-tz @member.createdAtUTC) "D MMM YYYY"}} -

- {{#if this.referrerSource}} -

- {{svg-jar "earth"}} - Source — {{this.referrerSource}} -

- {{/if}} - {{#if (and @member.attribution.url @member.attribution.title)}} -

- {{svg-jar "posts"}} - Page — {{ @member.attribution.title }} -

- {{!-- Learn more → --}} - {{/if}} -
- - {{#if (and (not-eq this.settings.membersSignupAccess "none") (not-eq this.settings.editorDefaultEmailRecipients "disabled"))}} -
-

Engagement

- {{#if (eq @member.emailCount 0)}} -
-

- {{#if @member.name}} - We’ll show {{first-name @member.name}}’s email stats here once they receive their first newsletter. - {{else}} - We’ll show this member’s email stats here once they receive their first newsletter. - {{/if}} -

-
- {{else}} -
-
-

Emails received

-
{{@member.emailCount}}
-
-
-

Emails opened

-
{{@member.emailOpenedCount}}
-
-
-

Average open rate

-
- {{#if (is-empty @member.emailOpenRate)}} - This metric is calculated once a member has received 5 newsletters. - {{else}} - {{@member.emailOpenRate}}% - {{/if}} -
-
-
- {{/if}} -
- {{/if}} - {{/unless}} -
-
diff --git a/apps/ember-admin/app/components/gh-member-details.js b/apps/ember-admin/app/components/gh-member-details.js deleted file mode 100644 index bf8682e8511..00000000000 --- a/apps/ember-admin/app/components/gh-member-details.js +++ /dev/null @@ -1,16 +0,0 @@ -import Component from '@glimmer/component'; -import {inject as service} from '@ember/service'; - -export default class extends Component { - @service feature; - @service settings; - - get referrerSource() { - const source = this.args.member.get('attribution')?.referrer_source; - return source === 'Created manually' ? null : source; - } - - get showAttribution() { - return this.referrerSource || (this.args.member?.get('attribution')?.url && this.args.member?.get('attribution')?.title); - } -} diff --git a/apps/ember-admin/app/components/gh-members-segment-select.hbs b/apps/ember-admin/app/components/gh-members-segment-select.hbs deleted file mode 100644 index a40bbcdefc1..00000000000 --- a/apps/ember-admin/app/components/gh-members-segment-select.hbs +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/apps/ember-admin/app/components/gh-members-segment-select.js b/apps/ember-admin/app/components/gh-members-segment-select.js deleted file mode 100644 index ece066021c4..00000000000 --- a/apps/ember-admin/app/components/gh-members-segment-select.js +++ /dev/null @@ -1,106 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {groupTiersByActive} from 'ghost-admin/utils/group-tiers'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class GhMembersSegmentSelect extends Component { - @service store; - @service feature; - - @tracked _baseOptions = []; - - get renderInPlace() { - return this.args.renderInPlace === undefined ? false : this.args.renderInPlace; - } - - constructor() { - super(...arguments); - this.fetchOptionsTask.perform(); - } - - get nonLabelOptions() { - if (this.args.hideOptionsWhenAllSelected) { - const segments = (this.args.segment || '').split(','); - if (segments.includes('status:free') && segments.includes('status:-free')) { - return this._baseOptions.filter(option => !option.groupName); - } - } - - return this._baseOptions; - } - - get selectedSegments() { - return (this.args.segment || '').split(',').filter(Boolean); - } - - get hideLabelsComputed() { - return !!this.args.hideLabels; - } - - @action - setSegment(options) { - const segment = options.mapBy('segment').join(',') || null; - this.args.onChange?.(segment); - } - - @task - *fetchOptionsTask() { - const options = yield []; - - if (!this.args.hideDefaultSegments) { - options.push({ - name: 'Free members', - segment: 'status:free', - class: 'segment-status-free' - }, { - name: 'Paid members', - segment: 'status:-free', // paid & comped & gift - class: 'segment-status-paid' - }); - } - - // fetch all tiers w̶i̶t̶h̶ c̶o̶u̶n̶t̶s̶ - // TODO: add `include: 'count.members` to query once API supports - const tiers = yield this.store.query('tier', {filter: 'type:paid', limit: 'all', include: 'monthly_price,yearly_price,benefits'}); - - if (tiers.length > 0) { - const [activeTiersGroup, archivedTiersGroup] = groupTiersByActive(tiers, tier => ({ - name: tier.name, - segment: `${tier.id}`, - count: tier.count?.members, - class: 'segment-tier' - })); - - options.push(activeTiersGroup); - options.push(archivedTiersGroup); - - if (this.args.selectDefaultTier && !this.args.segment) { - this.args.onChange?.(activeTiersGroup.options[0].segment); - } - } - - const offers = yield this.store.findAll('offer'); - - if (offers.length > 0) { - const offersGroup = { - groupName: 'Offers', - options: [] - }; - - offers.forEach((offer) => { - offersGroup.options.push({ - name: offer.name, - segment: `offer_redemptions:${offer.id}`, - count: offer.count?.members, - class: 'segment-offer' - }); - }); - - options.push(offersGroup); - } - - this._baseOptions = options; - } -} diff --git a/apps/ember-admin/app/components/gh-resource-select.hbs b/apps/ember-admin/app/components/gh-resource-select.hbs deleted file mode 100644 index f83b360a9f2..00000000000 --- a/apps/ember-admin/app/components/gh-resource-select.hbs +++ /dev/null @@ -1,19 +0,0 @@ - - {{resource.title}} - diff --git a/apps/ember-admin/app/components/gh-resource-select.js b/apps/ember-admin/app/components/gh-resource-select.js deleted file mode 100644 index 37916d11839..00000000000 --- a/apps/ember-admin/app/components/gh-resource-select.js +++ /dev/null @@ -1,184 +0,0 @@ -import Component from '@glimmer/component'; -import {A} from '@ember/array'; -import {action, get} from '@ember/object'; -import { - defaultMatcher, - filterOptions -} from 'ember-power-select/utils/group-utils'; -import {escapeNqlString} from '@tryghost/nql-string'; -import {inject as service} from '@ember/service'; -import {task, timeout} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -const DEBOUNCE_MS = 200; - -function mapResource(resource) { - return { - id: resource.id, - title: resource.title - }; -} - -export default class GhResourceSelect extends Component { - @service store; - - @tracked _options = []; - - get renderInPlace() { - return this.args.renderInPlace === undefined ? false : this.args.renderInPlace; - } - - get searchField() { - return this.args.searchField === undefined ? 'title' : this.args.searchField; - } - - @action - searchAndSuggest(term, select) { - return this.searchAndSuggestTask.perform(term, select); - } - - @task - *searchAndSuggestTask(term) { - let newOptions = this.flatOptions.toArray(); - - if (term.length === 0) { - return newOptions; - } - - // todo: we can do actual filtering on posts here (allow searching when we have lots and lots of posts) - yield undefined; - - newOptions = this._filter(A(newOptions), term); - - if (newOptions.length === 0) { - // Do a query lookup - newOptions = yield this.fetchOptionsForSearchTask.perform(term); - } - - return newOptions; - } - - get matcher() { - return this.args.matcher || defaultMatcher; - } - - _filter(options, searchText) { - let matcher; - if (this.searchField) { - matcher = (option, text) => this.matcher(get(option, this.searchField), text); - } else { - matcher = (option, text) => this.matcher(option, text); - } - return filterOptions(options || [], searchText, matcher); - } - - constructor() { - super(...arguments); - this.fetchOptionsTask.perform(); - } - - get options() { - return this._options; - } - - get flatOptions() { - const options = []; - - function getOptions(option) { - if (option.options) { - return option.options.forEach(getOptions); - } - - options.push(option); - } - - this._options.forEach(getOptions); - - return options; - } - - get selectedOption() { - if (this.args.resource.title) { - return this.args.resource; - } - const resource = this.args.resource ?? {}; - return this.flatOptions.find(option => resource.id === option.id); - } - - @action - onChange(options) { - this.args.onChange(options); - } - - get placeholderText() { - if (this.args.type === 'email') { - return 'Select an email'; - } - return 'Select a page/post'; - } - - get searchPlaceholderText() { - if (this.args.type === 'email') { - return 'Search emails'; - } - return 'Search posts/pages'; - } - - @task - *fetchOptionsTask() { - const options = yield []; - - if (this.args.type === 'email') { - const posts = yield this.store.query('post', {filter: '(status:published,status:sent)+newsletter_id:-null', limit: '25', fields: 'id,title'}); - options.push(...posts.map(mapResource)); - this._options = options; - return; - } - - const posts = yield this.store.query('post', {filter: 'status:published', limit: '25', fields: 'id,title'}); - const pages = yield this.store.query('page', {filter: 'status:published', limit: '25', fields: 'id,title'}); - - if (posts.length > 0) { - options.push({ - groupName: 'Posts', - options: posts.map(mapResource) - }); - } - - if (pages.length > 0) { - options.push({ - groupName: 'Pages', - options: pages.map(mapResource) - }); - } - - this._options = options; - } - - @task({restartable: true}) - *fetchOptionsForSearchTask(searchTerm) { - // Debounce - yield timeout(DEBOUNCE_MS); - - const options = yield []; - - if (this.args.type === 'email') { - const posts = yield this.store.query('post', {filter: '(status:published,status:sent)+newsletter_id:-null+title:~' + escapeNqlString(searchTerm), limit: '10', fields: 'id,title'}); - options.push(...posts.map(mapResource)); - return options; - } - - const posts = yield this.store.query('post', {filter: 'status:published+title:~' + escapeNqlString(searchTerm), limit: '10', fields: 'id,title'}); - const pages = yield this.store.query('page', {filter: 'status:published+title:~' + escapeNqlString(searchTerm), limit: '10', fields: 'id,title'}); - - if (posts.length > 0) { - options.push(...posts.map(mapResource)); - } - - if (pages.length > 0) { - options.push(...pages.map(mapResource)); - } - - return options; - } -} diff --git a/apps/ember-admin/app/components/gh-theme-error-li.hbs b/apps/ember-admin/app/components/gh-theme-error-li.hbs deleted file mode 100644 index 3ef6d909594..00000000000 --- a/apps/ember-admin/app/components/gh-theme-error-li.hbs +++ /dev/null @@ -1,31 +0,0 @@ - -
-
-

- {{{this.error.rule}}} -

-
- {{#if this.showDetails}} - {{svg-jar "arrow-down"}} - {{else}} - {{svg-jar "arrow-right"}} - {{/if}} -
-
-
- -{{#if this.showDetails}} -

- {{{this.error.details}}} -

- {{#if this.error.failures}} -
-
Affected files:
-
    - {{#each this.error.failures as |failure|}} -
  • {{failure.ref}}{{#if failure.message}}: {{failure.message}}{{/if}}
  • - {{/each}} -
-
- {{/if}} -{{/if}} diff --git a/apps/ember-admin/app/components/gh-theme-error-li.js b/apps/ember-admin/app/components/gh-theme-error-li.js deleted file mode 100644 index 489a120609e..00000000000 --- a/apps/ember-admin/app/components/gh-theme-error-li.js +++ /dev/null @@ -1,16 +0,0 @@ -import Component from '@ember/component'; -import classic from 'ember-classic-decorator'; -import {action} from '@ember/object'; -import {tagName} from '@ember-decorators/component'; - -@classic -@tagName('') -export default class GhThemeErrorLi extends Component { - error = null; - showDetails = false; - - @action - toggleDetails() { - this.toggleProperty('showDetails'); - } -} diff --git a/apps/ember-admin/app/components/gh-token-input/label-selected-item.hbs b/apps/ember-admin/app/components/gh-token-input/label-selected-item.hbs deleted file mode 100644 index 51a1151fe62..00000000000 --- a/apps/ember-admin/app/components/gh-token-input/label-selected-item.hbs +++ /dev/null @@ -1 +0,0 @@ -{{@option.name}} diff --git a/apps/ember-admin/app/components/gh-token-input/label-token.hbs b/apps/ember-admin/app/components/gh-token-input/label-token.hbs deleted file mode 100644 index 889d9eeadc1..00000000000 --- a/apps/ember-admin/app/components/gh-token-input/label-token.hbs +++ /dev/null @@ -1 +0,0 @@ -{{yield}} diff --git a/apps/ember-admin/app/components/gh-token-input/label-token.js b/apps/ember-admin/app/components/gh-token-input/label-token.js deleted file mode 100644 index 0327c9c3924..00000000000 --- a/apps/ember-admin/app/components/gh-token-input/label-token.js +++ /dev/null @@ -1,17 +0,0 @@ -import DraggableObject from 'ember-drag-drop/components/draggable-object'; -import classic from 'ember-classic-decorator'; -import {alias} from '@ember/object/computed'; -import {attributeBindings, classNames} from '@ember-decorators/component'; -import {computed} from '@ember/object'; - -@classic -@attributeBindings('title') -@classNames('label-token') -export default class LabelToken extends DraggableObject { - @alias('content.name') name; - - @computed('name') - get title() { - return this.name ?? 'Label'; - } -} diff --git a/apps/ember-admin/app/components/gh-twitter-url-input.hbs b/apps/ember-admin/app/components/gh-twitter-url-input.hbs deleted file mode 100644 index 22635bfafc6..00000000000 --- a/apps/ember-admin/app/components/gh-twitter-url-input.hbs +++ /dev/null @@ -1,10 +0,0 @@ - \ No newline at end of file diff --git a/apps/ember-admin/app/components/gh-twitter-url-input.js b/apps/ember-admin/app/components/gh-twitter-url-input.js deleted file mode 100644 index 0f86e7c8f63..00000000000 --- a/apps/ember-admin/app/components/gh-twitter-url-input.js +++ /dev/null @@ -1,63 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; - -export default class GhTwitterUrlInput extends Component { - get value() { - const {model, modelProperty, scratchValue} = this.args; - return scratchValue || model[modelProperty]; - } - - @action - setScratchValue(event) { - this.args.setScratchValue?.(event.target.value); - } - - @action - setTwitterUrl(event) { - const {model, modelProperty} = this.args; - - const newUrl = event.target.value; - - // reset errors and validation - model.errors.remove(modelProperty); - model.hasValidated.removeObject(modelProperty); - - if (!newUrl) { - // Clear out the Twitter url - model[modelProperty] = ''; - this.args.setScratchValue?.(null); - return; - } - - if (newUrl.match(/(?:twitter\.com\/)(\S+)/) || newUrl.match(/([a-z\d.]+)/i)) { - let username = []; - - if (newUrl.match(/(?:twitter\.com\/)(\S+)/)) { - [, username] = newUrl.match(/(?:twitter\.com\/)(\S+)/); - } else { - [username] = newUrl.match(/([^/]+)\/?$/mi); - } - - // check if username starts with http or www and show error if so - if (username.match(/^(http|www)|(\/)/) || !username.match(/^[a-z\d._]{1,15}$/mi)) { - const message = !username.match(/^[a-z\d._]{1,15}$/mi) - ? 'Your Username is not a valid Twitter Username' - : 'The URL must be in a format like https://twitter.com/yourUsername'; - - model.errors.add(modelProperty, message); - model.hasValidated.pushObject(modelProperty); - return; - } - - model[modelProperty] = `https://twitter.com/${username}`; - this.args.setScratchValue?.(null); - - model.hasValidated.pushObject(modelProperty); - } else { - const message = 'The URL must be in a format like https://twitter.com/yourUsername'; - model.errors.add(modelProperty, message); - model.hasValidated.pushObject(modelProperty); - return; - } - } -} diff --git a/apps/ember-admin/app/components/gh-url-input.hbs b/apps/ember-admin/app/components/gh-url-input.hbs deleted file mode 100644 index 121592c5851..00000000000 --- a/apps/ember-admin/app/components/gh-url-input.hbs +++ /dev/null @@ -1,9 +0,0 @@ - - diff --git a/apps/ember-admin/app/components/gh-url-input.js b/apps/ember-admin/app/components/gh-url-input.js deleted file mode 100644 index 98da92e75a3..00000000000 --- a/apps/ember-admin/app/components/gh-url-input.js +++ /dev/null @@ -1,40 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; - -function ensureEndsWith(string, endsWith) { - return string.endsWith(endsWith) ? string : string + endsWith; -} - -function removeLeadingSlash(string) { - return string.replace(/^\//, ''); -} - -export default class GhUrlInput extends Component { - constructor(owner, args) { - super(owner, args); - this.baseUrl = ensureEndsWith(args.baseUrl, '/'); - this.value = args.value && args.value !== '/' ? (new URL(removeLeadingSlash(args.value), this.baseUrl)).href : ''; - this.setResult = args.setResult; - this.validateUrl = args.validateUrl; - this.placeholder = args.placeholder; - } - - @action - setValue(event) { - this.value = event.target.value; - this.setResult(this.result); - } - - @action - validateUrlInput() { - this.validateUrl(this.result); - } - - get result() { - try { - return new URL(removeLeadingSlash(this.value), this.baseUrl); - } catch (err) { - return null; - } - } -} diff --git a/apps/ember-admin/app/components/gh-view-title.hbs b/apps/ember-admin/app/components/gh-view-title.hbs deleted file mode 100644 index 889d9eeadc1..00000000000 --- a/apps/ember-admin/app/components/gh-view-title.hbs +++ /dev/null @@ -1 +0,0 @@ -{{yield}} diff --git a/apps/ember-admin/app/components/gh-view-title.js b/apps/ember-admin/app/components/gh-view-title.js deleted file mode 100644 index a5bd75253ff..00000000000 --- a/apps/ember-admin/app/components/gh-view-title.js +++ /dev/null @@ -1,9 +0,0 @@ -import Component from '@ember/component'; -import classic from 'ember-classic-decorator'; -import {classNames, tagName} from '@ember-decorators/component'; - -@classic -@tagName('h2') -@classNames('view-title') -export default class GhViewTitle extends Component { -} diff --git a/apps/ember-admin/app/components/liquid-container.js b/apps/ember-admin/app/components/liquid-container.js deleted file mode 100644 index 537addc7888..00000000000 --- a/apps/ember-admin/app/components/liquid-container.js +++ /dev/null @@ -1,12 +0,0 @@ -import LiquidContainer from 'liquid-fire/components/liquid-container'; -import config from 'ghost-admin/config/environment'; - -export default LiquidContainer.extend({ - init() { - this._super(...arguments); - - if (config.environment === 'test') { - this.growDuration = 5; - } - } -}); diff --git a/apps/ember-admin/app/components/members-activity/member-filter-trigger.hbs b/apps/ember-admin/app/components/members-activity/member-filter-trigger.hbs deleted file mode 100644 index 0c7005fb90b..00000000000 --- a/apps/ember-admin/app/components/members-activity/member-filter-trigger.hbs +++ /dev/null @@ -1 +0,0 @@ -
Filter member {{svg-jar "arrow-down-small"}}
diff --git a/apps/ember-admin/app/components/modal-impersonate-member.hbs b/apps/ember-admin/app/components/modal-impersonate-member.hbs deleted file mode 100644 index d275df893b6..00000000000 --- a/apps/ember-admin/app/components/modal-impersonate-member.hbs +++ /dev/null @@ -1,54 +0,0 @@ - -{{!-- disable mouseDown so it doesn't trigger focus-out validations --}} - - {{svg-jar "close"}} - - - - -
-

This link is only valid for the next 24 hours

-
diff --git a/apps/ember-admin/app/components/modal-impersonate-member.js b/apps/ember-admin/app/components/modal-impersonate-member.js deleted file mode 100644 index 6041e3dfde4..00000000000 --- a/apps/ember-admin/app/components/modal-impersonate-member.js +++ /dev/null @@ -1,40 +0,0 @@ -import ModalComponent from 'ghost-admin/components/modal-base'; -import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard'; -import {alias} from '@ember/object/computed'; -import {inject} from 'ghost-admin/decorators/inject'; -import {inject as service} from '@ember/service'; -import {task, timeout} from 'ember-concurrency'; - -export default ModalComponent.extend({ - store: service(), - - classNames: 'modal-impersonate-member', - - signinUrl: null, - config: inject(), - - member: alias('model'), - - didInsertElement() { - this._super(...arguments); - - this._signinUrlUpdateTask.perform(); - }, - - actions: { - // noop - we don't want the enter key doing anything - confirm() {} - }, - - copySigninUrl: task(function* () { - copyTextToClipboard(this.signinUrl); - yield timeout(1000); - return true; - }), - - _signinUrlUpdateTask: task(function*() { - const memberSigninURL = yield this.member.fetchSigninUrl.perform(); - - this.set('signinUrl', memberSigninURL.url); - }).drop() -}); diff --git a/apps/ember-admin/app/components/modal-members-label-form.hbs b/apps/ember-admin/app/components/modal-members-label-form.hbs deleted file mode 100644 index f922edfd26c..00000000000 --- a/apps/ember-admin/app/components/modal-members-label-form.hbs +++ /dev/null @@ -1,71 +0,0 @@ -{{#if this.showDeleteLabelModal}} - - {{svg-jar "close"}} - - - - -{{else}} - - - {{!-- disable mouseDown so it doesn't trigger focus-out validations --}} - - - - - -{{/if}} \ No newline at end of file diff --git a/apps/ember-admin/app/components/modal-members-label-form.js b/apps/ember-admin/app/components/modal-members-label-form.js deleted file mode 100644 index 9fb6a042a86..00000000000 --- a/apps/ember-admin/app/components/modal-members-label-form.js +++ /dev/null @@ -1,90 +0,0 @@ -import ModalComponent from 'ghost-admin/components/modal-base'; -import {and} from '@ember/object/computed'; -import {resetQueryParams} from 'ghost-admin/helpers/reset-query-params'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; - -export default ModalComponent.extend({ - router: service(), - notifications: service(), - labelsManager: service(), - model: null, - showDeleteLabelModal: false, - - confirm() {}, - label: and('model', 'model.label'), - - willDestroyElement() { - this._super(...arguments); - this.label.errors.clear(); - this.label.rollbackAttributes(); - }, - - actions: { - toggleDeleteLabelModal() { - this.label.rollbackAttributes(); - this.set('showDeleteLabelModal', true); - }, - - validate(property) { - return this.label.validate({property}); - }, - - confirm() { - return this.saveTask.perform(); - } - }, - - saveTask: task(function* () { - const label = this.model && this.model.label; - const availableLabels = (this.model && this.model.labels) || []; - if (!label) { - return false; - } - try { - yield label.validate(); - - const duplicateLabel = availableLabels.find((existingLabel) => { - return existingLabel.name.trim().toLowerCase() === label.name.trim().toLowerCase() - && existingLabel.slug !== label.slug; - }); - - if (duplicateLabel) { - label.errors.add('name', 'A label with the same name already exists'); - label.hasValidated.pushObject('name'); - // label.invalidate(); - - return false; - } - - const savedLabel = yield label.save(); - this.notifications.showNotification('Label saved'); - this.send('closeModal'); - return savedLabel; - } catch (error) { - if (error) { - this.notifications.showAPIError(error, {key: 'label.save'}); - } - } - }), - - deleteLabel: task(function * () { - const label = this.model && this.model.label; - if (!label) { - return false; - } - try { - yield label.destroyRecord(); - this.labelsManager.removeLabel(label); - const routeName = this.router.currentRouteName; - this.notifications.showNotification('Label deleted'); - this.send('closeModal'); - this.router.transitionTo(routeName, {queryParams: resetQueryParams(routeName)}); - } catch (error) { - if (error) { - return this.notifications.showAPIError(error, {key: 'label.delete'}); - } - } - }) - -}); diff --git a/apps/ember-admin/app/components/modals/new-custom-integration.hbs b/apps/ember-admin/app/components/modals/new-custom-integration.hbs deleted file mode 100644 index 8a584fbe522..00000000000 --- a/apps/ember-admin/app/components/modals/new-custom-integration.hbs +++ /dev/null @@ -1,48 +0,0 @@ - \ No newline at end of file diff --git a/apps/ember-admin/app/components/modals/new-custom-integration.js b/apps/ember-admin/app/components/modals/new-custom-integration.js deleted file mode 100644 index 7073fadacd3..00000000000 --- a/apps/ember-admin/app/components/modals/new-custom-integration.js +++ /dev/null @@ -1,64 +0,0 @@ -import Component from '@glimmer/component'; -import {A} from '@ember/array'; -import {action} from '@ember/object'; -import {isHostLimitError} from 'ghost-admin/services/ajax'; -import {isInvalidError} from 'ember-ajax/errors'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class NewCustomIntegrationModal extends Component { - @service router; - @service store; - - @tracked errorMessage; - - constructor() { - super(...arguments); - this.integration = this.store.createRecord('integration'); - } - - willDestroy() { - super.willDestroy(...arguments); - this.integration.rollbackAttributes(); - } - - @action - updateName(inputEvent) { - this.integration.set('name', inputEvent.target.value); - this.integration.set('hasValidated', A()); - this.integration.errors.clear(); - } - - @task({drop: true}) - *createIntegrationTask() { - try { - const integration = yield this.integration.save(); - this.router.transitionTo('settings.integration', integration); - return true; - } catch (error) { - // TODO: server-side validation errors should be serialized - // properly so that errors are added to model.errors automatically - if (error && isInvalidError(error)) { - const [firstError] = error.payload.errors; - const {message} = firstError; - - if (message && message.match(/name/i)) { - this.integration.errors.add('name', message); - this.integration.hasValidated.pushObject('name'); - return; - } - } - - if (isHostLimitError(error)) { - this.errorMessage = error.payload.errors[0].context; - return; - } - - // bubble up to the global error handler - if (error) { - throw error; - } - } - } -} diff --git a/apps/ember-admin/app/components/posts-list/list-item.hbs b/apps/ember-admin/app/components/posts-list/list-item.hbs deleted file mode 100644 index 1e69154a316..00000000000 --- a/apps/ember-admin/app/components/posts-list/list-item.hbs +++ /dev/null @@ -1,274 +0,0 @@ -{{!-- template-lint-disable no-invalid-interactive --}} -
  • - - {{!-- Title column --}} - {{#if (and this.session.user.isContributor @post.isPublished)}} - - {{else}} - -

    - {{#if @post.featured}} - {{svg-jar "star-fill" class="gh-featured-post"}} - {{/if}} - {{@post.title}} -

    - {{#unless @hideAuthor }} - -

    - {{#if @post.isScheduled}} - - Scheduled - {{#if this.isHovered}} - - {{#if @post.emailOnly}} - to be sent - {{this.scheduledText}} to {{humanize-recipient-filter @post.emailSegment}} - {{else}} - to be published {{if @post.newsletter "and sent "}} - {{this.scheduledText}} - {{#if @post.newsletter}} - to {{humanize-recipient-filter @post.emailSegment}} - {{/if}} - {{/if}} - - {{/if}} - - {{/if}} - - {{#if @post.isDraft}} - - Draft - - {{/if}} - - {{#if @post.isPublished}} - - Published - {{#if @post.didEmailFail}} - but failed to send newsletter - {{else if @post.hasBeenEmailed}} - and sent - {{#if this.isHovered}} - to {{gh-pluralize @post.email.emailCount "member"}} - {{/if}} - {{/if}} - - {{/if}} - - {{#if @post.isSent}} - - {{#if @post.didEmailFail}} - Failed to send newsletter - {{else}} - Sent - {{#if this.isHovered}} - to {{gh-pluralize @post.email.emailCount "member"}} - {{/if}} - {{/if}} - - {{/if}} -

    - {{/unless}} -
    - {{/if}} - - {{!-- Metrics columns wrapper --}} -
    - {{!-- Opened / Signups column --}} - {{#if (and @post.showEmailOpenAnalytics @post.showEmailClickAnalytics) }} - - {{else}} - - {{/if}} - - {{!-- Clicked / Conversions column --}} - {{#if @post.showEmailClickAnalytics }} - - {{else}} - {{#if @post.showEmailOpenAnalytics }} - - {{else}} - - {{/if}} - {{/if}} - - {{!-- Visitor count column (only show for published posts when traffic analytics is enabled AND web analytics is enabled) --}} - {{#if this.settings.webAnalyticsEnabled}} - {{#if @post.isPublished}} - {{#if this.hasVisitorData}} - - {{else}} - - {{/if}} - {{/if}} - {{/if}} - - {{!-- Member conversions column (only show for published posts when member tracking is enabled) --}} - {{#if this.settings.membersTrackSources}} - {{#if @post.isPublished}} - {{#if this.hasMemberData}} - - {{else}} - - {{/if}} - {{/if}} - {{/if}} -
    - - {{!-- Button column --}} - {{#if @post.hasAnalyticsPage }} - {{#if (gh-user-can-admin this.session.user)}} - - {{/if}} - {{else}} - {{#if (and this.session.user.isContributor @post.isPublished)}} - - {{else}} - - - {{svg-jar "pen" title="Go to Editor"}} - - - {{/if}} - {{/if}} -
  • diff --git a/apps/ember-admin/app/components/posts-list/list-item.js b/apps/ember-admin/app/components/posts-list/list-item.js deleted file mode 100644 index 1874f88cb90..00000000000 --- a/apps/ember-admin/app/components/posts-list/list-item.js +++ /dev/null @@ -1,73 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {formatPostTime} from 'ghost-admin/helpers/gh-format-post-time'; -import {inject} from 'ghost-admin/decorators/inject'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -export default class PostsListItemClicks extends Component { - @service feature; - @service session; - @service settings; - @service postAnalytics; - - @tracked isHovered = false; - - @inject config; - - get post() { - return this.args.post; - } - - get errorClass() { - if (this.post.didEmailFail) { - return 'error'; - } - return ''; - } - - get scheduledText() { - const text = []; - - const formattedTime = formatPostTime( - this.post.publishedAtUTC, - {timezone: this.settings.timezone, scheduled: true} - ); - text.push(formattedTime); - - return text.join(' '); - } - - get visitorCount() { - return this.postAnalytics.getVisitorCount(this.post.uuid); - } - - get hasVisitorData() { - return this.visitorCount !== null; - } - - get memberCounts() { - return this.postAnalytics.getMemberCounts(this.post.uuid); - } - - get hasMemberData() { - return this.memberCounts !== null; - } - - get totalMemberConversions() { - if (!this.memberCounts) { - return 0; - } - return this.memberCounts.free + this.memberCounts.paid; - } - - @action - mouseOver() { - this.isHovered = true; - } - - @action - mouseLeave() { - this.isHovered = false; - } -} diff --git a/apps/ember-admin/app/helpers/activity-feed-fetcher.js b/apps/ember-admin/app/helpers/activity-feed-fetcher.js deleted file mode 100644 index 0d2558e5226..00000000000 --- a/apps/ember-admin/app/helpers/activity-feed-fetcher.js +++ /dev/null @@ -1,155 +0,0 @@ -import moment from 'moment-timezone'; -import {Resource} from 'ember-could-get-used-to-this'; -import {TrackedArray} from 'tracked-built-ins'; -import {action} from '@ember/object'; -import {didCancel, task} from 'ember-concurrency'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -const actions = { - showPrevious: 'showPrevious', - showNext: 'showNext' -}; - -export default class ActivityFeedFetcher extends Resource { - @service ajax; - @service ghostPaths; - @service store; - - @tracked data = new TrackedArray([]); - @tracked isLoading = false; - @tracked isError = false; - @tracked errorMessage = null; - - @tracked hasReachedStart = true; - @tracked hasReachedEnd = true; - - @tracked shownEvents = 0; - @tracked totalEvents = 0; - - // Save the pagination filter for each page so we can return easily - @tracked eventsBookmarks = []; - - get value() { - return { - isLoading: this.isLoading, - isError: this.isError, - errorMessage: this.errorMessage, - data: this.data, - loadNextPage: this.loadNextPage, - loadPreviousPage: this.loadPreviousPage, - hasReachedStart: this.hasReachedStart, - hasReachedEnd: this.hasReachedEnd, - totalEvents: this.totalEvents, - shownEvents: this.shownEvents, - previousEvents: this.getAmountOfPreviousEvents() - }; - } - - getAmountOfPreviousEvents() { - return this.shownEvents - this.data.length + 1; - } - - async setup() { - const currentTime = moment.utc().format('YYYY-MM-DD HH:mm:ss'); - let filter = `data.created_at:<'${currentTime}'`; - this.eventsBookmarks.push(filter); - - if (this.args.named.filter) { - filter += `+${this.args.named.filter}`; - } - - try { - await this.loadEventsTask.perform({filter}, actions.showNext); - } catch (e) { - if (!didCancel(e)) { - // re-throw the non-cancelation error - throw e; - } - } - } - - @action - loadNextPage() { - if (this.hasReachedEnd) { - return; - } - const lastEvent = this.data[this.data.length - 1]; - const lastEventDate = moment.utc(lastEvent.data.created_at).format('YYYY-MM-DD HH:mm:ss'); - const lastEventId = lastEvent.data.id; - - let filter = `(data.created_at:<'${lastEventDate}',(data.created_at:'${lastEventDate}'+id:<'${lastEventId}'))`; - this.eventsBookmarks.push(filter); - - if (this.args.named.filter) { - filter += `+${this.args.named.filter}`; - } - - this.loadEventsTask.perform({filter}, actions.showNext); - } - - @action - loadPreviousPage() { - if (this.hasReachedStart) { - return; - } - this.eventsBookmarks.pop(); - let filter = this.eventsBookmarks[this.eventsBookmarks.length - 1]; - - if (this.args.named.filter) { - filter += `+${this.args.named.filter}`; - } - - this.shownEvents = this.shownEvents - this.data.length; - - this.loadEventsTask.perform({filter}, actions.showPrevious); - } - - updateState(meta, actionType) { - if (!this.data.length) { - return; - } - - if (!this.totalEvents) { - this.totalEvents = meta.pagination.total; - } - - if (actionType === actions.showNext) { - this.shownEvents = this.shownEvents + this.data.length; - } - - this.hasReachedStart = this.totalEvents === meta.pagination.total; - this.hasReachedEnd = this.shownEvents === this.totalEvents; - - // todo: it's temporarily fix, pagination breaks if few events happen at the same time, easy to reproduce on email clicks - if ((this.shownEvents < this.totalEvents) && (this.data.length < this.args.named.pageSize)) { - this.hasReachedEnd = true; - } - } - - @task - *loadEventsTask(queryParams, actionType) { - try { - this.isLoading = true; - - const url = this.ghostPaths.url.api('members/events'); - const data = Object.assign({}, queryParams, {limit: this.args.named.pageSize}); - const {events, meta} = yield this.ajax.request(url, {data}); - - this.data = events; - this.updateState(meta, actionType); - } catch (e) { - this.isError = true; - - const errorMessage = e.payload?.errors?.[0]?.message; - if (errorMessage) { - this.errorMessage = errorMessage; - } - - // TODO: log to Sentry - console.error(e); // eslint-disable-line - } finally { - this.isLoading = false; - } - } -} diff --git a/apps/ember-admin/app/helpers/currency-symbol.js b/apps/ember-admin/app/helpers/currency-symbol.js deleted file mode 100644 index 85363e7afed..00000000000 --- a/apps/ember-admin/app/helpers/currency-symbol.js +++ /dev/null @@ -1,14 +0,0 @@ -import Helper from '@ember/component/helper'; -import {getSymbol} from 'ghost-admin/utils/currency'; -import {inject as service} from '@ember/service'; - -export default class CurrencySymbolHelper extends Helper { - @service feature; - - compute([currency]) { - if (currency) { - return getSymbol(currency); - } - return ''; - } -} diff --git a/apps/ember-admin/app/helpers/full-email-address.js b/apps/ember-admin/app/helpers/full-email-address.js deleted file mode 100644 index 274723a629c..00000000000 --- a/apps/ember-admin/app/helpers/full-email-address.js +++ /dev/null @@ -1,14 +0,0 @@ -import Helper from '@ember/component/helper'; -import {inject} from 'ghost-admin/decorators/inject'; - -export default class FullEmailAddressHelper extends Helper { - @inject config; - - compute([email = '']) { - if (email.indexOf('@') > -1) { - return email; - } - - return `${email}@${this.config.emailDomain}`; - } -} diff --git a/apps/ember-admin/app/helpers/gh-count-characters.js b/apps/ember-admin/app/helpers/gh-count-characters.js deleted file mode 100644 index 74e3072b4d7..00000000000 --- a/apps/ember-admin/app/helpers/gh-count-characters.js +++ /dev/null @@ -1,31 +0,0 @@ -import {helper} from '@ember/component/helper'; -import {htmlSafe} from '@ember/template'; - -export function countCharacters(params) { - if (!params || !params.length) { - return; - } - - const el = document.createElement('span'); - const content = params[0] || ''; - - // convert to array so that we get accurate symbol counts for multibyte chars - // this will still count emoji+modifer as two chars - const {length} = Array.from(content); - - el.className = 'word-count'; - - if (length > 180) { - el.style.color = '#f05230'; - } else { - el.style.color = '#45C32E'; - } - - el.innerHTML = 200 - length; - - return htmlSafe(el.outerHTML); -} - -export default helper(function (params) { - return countCharacters(params); -}); diff --git a/apps/ember-admin/app/helpers/gh-count-down-html-characters.js b/apps/ember-admin/app/helpers/gh-count-down-html-characters.js deleted file mode 100644 index bb1df3558db..00000000000 --- a/apps/ember-admin/app/helpers/gh-count-down-html-characters.js +++ /dev/null @@ -1,21 +0,0 @@ -import {countDownCharacters} from './gh-count-down-characters'; -import {helper} from '@ember/component/helper'; - -export default helper(function (params) { - let [content, maxCharacters] = params; - - if (!content) { - // Protect against NULL content - content = ''; - } - - // Strip HTML-tags and characters from content so we have a reliable character count - content = content.replace(/<[^>]*>?/gm, ''); - content = content.replace(/ /g, ' '); - content = content.replace(/&/g, '&'); - content = content.replace(/"/g, '"'); - content = content.replace(/</g, '<'); - content = content.replace(/>/g, '>'); - - return countDownCharacters([content, maxCharacters]); -}); diff --git a/apps/ember-admin/app/helpers/gh-price-amount.js b/apps/ember-admin/app/helpers/gh-price-amount.js deleted file mode 100644 index e0e6ed3c187..00000000000 --- a/apps/ember-admin/app/helpers/gh-price-amount.js +++ /dev/null @@ -1,19 +0,0 @@ -import {formatNumber} from './format-number'; -import {helper} from '@ember/component/helper'; - -export function ghPriceAmount(amount, {cents = true} = {}) { - if (amount) { - const price = cents ? amount / 100 : Math.round(amount / 100); - if (price % 1 === 0) { - return formatNumber(price); - } else { - return formatNumber(Math.round(price * 100) / 100, {minimumFractionDigits: 2}); - } - } - return 0; -} - -// like {{pluralize}} but formats the number according to current locale -export default helper(function ([amount], options = {}) { - return ghPriceAmount(amount, options); -}); diff --git a/apps/ember-admin/app/helpers/hex-contrast.js b/apps/ember-admin/app/helpers/hex-contrast.js deleted file mode 100644 index 6cbb96255da..00000000000 --- a/apps/ember-admin/app/helpers/hex-contrast.js +++ /dev/null @@ -1,6 +0,0 @@ -import {Color, textColorForBackgroundColor} from '@tryghost/color-utils'; -import {helper} from '@ember/component/helper'; - -export default helper(function hexContrast([hex]) { - return textColorForBackgroundColor(Color(hex)).hex(); -}); diff --git a/apps/ember-admin/app/helpers/history-event-fetcher.js b/apps/ember-admin/app/helpers/history-event-fetcher.js deleted file mode 100644 index 130fa2861cd..00000000000 --- a/apps/ember-admin/app/helpers/history-event-fetcher.js +++ /dev/null @@ -1,132 +0,0 @@ -import moment from 'moment-timezone'; -import {Resource} from 'ember-could-get-used-to-this'; -import {TrackedArray} from 'tracked-built-ins'; -import {action} from '@ember/object'; -import {didCancel, task} from 'ember-concurrency'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -export default class HistoryEventFetcher extends Resource { - @service ajax; - @service ghostPaths; - @service store; - - @tracked data = new TrackedArray([]); - @tracked isLoading = false; - @tracked isError = false; - @tracked errorMessage = null; - @tracked hasReachedEnd = false; - - cursor = null; - - get value() { - return { - isLoading: this.isLoading, - isError: this.isError, - errorMessage: this.errorMessage, - data: this.data, - loadNextPage: this.loadNextPage, - hasReachedEnd: this.hasReachedEnd - }; - } - - async setup() { - this.cursor = moment.utc().format('YYYY-MM-DD HH:mm:ss'); - let filter = `created_at:<'${this.cursor}'`; - - if (this.args.named.filter) { - filter += `+${this.args.named.filter}`; - } - - try { - await this.loadEventsTask.perform({filter}); - } catch (e) { - if (!didCancel(e)) { - // re-throw the non-cancelation error - throw e; - } - } - } - - @action - loadNextPage() { - // NOTE: assumes data is always ordered by created_at desc - const lastEvent = this.data[this.data.length - 1]; - - if (!lastEvent?.created_at) { - this.hasReachedEnd = true; - return; - } - - const cursor = moment.utc(lastEvent.created_at).format('YYYY-MM-DD HH:mm:ss'); - - if (cursor === this.cursor) { - this.hasReachedEnd = true; - return; - } - - this.cursor = cursor; - let filter = `created_at:<'${this.cursor}'`; - - if (this.args.named.filter) { - filter += `+${this.args.named.filter}`; - } - - this.loadEventsTask.perform({filter}); - } - - @task - *loadEventsTask(queryParams) { - try { - this.isLoading = true; - - const url = this.ghostPaths.url.api('actions'); - const data = Object.assign({}, queryParams, { - include: 'actor,resource', - limit: this.args.named.pageSize - }); - const {actions} = yield this.ajax.request(url, {data}); - - if (actions.length < data.limit) { - this.hasReachedEnd = true; - } - - let count = 1; - - actions.reverse().forEach((a, index) => { - const nextAction = actions[index + 1] || null; - - // depending on the similarity, add additional properties to be used on the frontend for grouping - // skip - used for hiding the event on the frontend - // count - the number of similar events which is added to the last item - if (nextAction || (!nextAction && actions[index - 1].skip)) { - if (nextAction && a.resource_id === nextAction.resource_id && a.event === nextAction.event) { - a.skip = true; - count += 1; - } else { - if (count > 1) { - a.count = count.toString(); - count = 1; - } - } - } - - a.context = JSON.parse(a.context); - }); - - this.data.push(...actions.reverse()); - } catch (e) { - this.isError = true; - - const errorMessage = e.payload?.errors?.[0]?.message; - if (errorMessage) { - this.errorMessage = errorMessage; - } - - // TODO: log to Sentry - console.error(e); // eslint-disable-line - } finally { - this.isLoading = false; - } - } -} diff --git a/apps/ember-admin/app/helpers/history-event-filter.js b/apps/ember-admin/app/helpers/history-event-filter.js deleted file mode 100644 index e3760862998..00000000000 --- a/apps/ember-admin/app/helpers/history-event-filter.js +++ /dev/null @@ -1,41 +0,0 @@ -import Helper from '@ember/component/helper'; -import classic from 'ember-classic-decorator'; -import {isBlank} from '@ember/utils'; - -@classic -export default class HistoryEventFilter extends Helper { - compute( - positionalParams, - {excludedEvents = [], excludedResources = [], user = ''} - ) { - const excludedEventsSet = new Set(); - const excludedResourcesSet = new Set(); - - if (excludedEvents.length) { - excludedEvents.forEach(type => excludedEventsSet.add(type)); - } - - if (excludedResources.length) { - excludedResources.forEach(type => excludedResourcesSet.add(type)); - } - - const filterParts = []; - - const excludedEventsArray = Array.from(excludedEventsSet).reject(isBlank); - if (excludedEventsArray.length > 0) { - filterParts.push(`event:-[${excludedEventsArray.join(',')}]`); - } - - const IGNORED_RESOURCES = ['label']; - const excludedResourcesArray = Array.from(excludedResourcesSet).concat(IGNORED_RESOURCES).reject(isBlank); - if (excludedResourcesArray.length > 0) { - filterParts.push(`resource_type:-[${excludedResourcesArray.join(',')}]`); - } - - if (user) { - filterParts.push(`actor_id:'${user}'`); - } - - return filterParts.join('+'); - } -} diff --git a/apps/ember-admin/app/helpers/humanize-setting-key.js b/apps/ember-admin/app/helpers/humanize-setting-key.js deleted file mode 100644 index eb5b032f716..00000000000 --- a/apps/ember-admin/app/helpers/humanize-setting-key.js +++ /dev/null @@ -1,17 +0,0 @@ -import {helper} from '@ember/component/helper'; -import {humanize} from 'ember-cli-string-helpers/helpers/humanize'; - -export function humanizeSettingKey([key]) { - let humanized = humanize([key]); - - const allCaps = ['API', 'CTA', 'RSS']; - - allCaps.forEach((str) => { - const regex = new RegExp(`(^| )(${str})( |$)`, 'gi'); - humanized = humanized.replace(regex, `$1${str}$3`); - }); - - return humanized; -} - -export default helper(humanizeSettingKey); diff --git a/apps/ember-admin/app/helpers/integration-icon-style.js b/apps/ember-admin/app/helpers/integration-icon-style.js deleted file mode 100644 index 627d3e493a6..00000000000 --- a/apps/ember-admin/app/helpers/integration-icon-style.js +++ /dev/null @@ -1,11 +0,0 @@ -import {helper} from '@ember/component/helper'; -import {htmlSafe} from '@ember/template'; - -export function integrationLogoStyle([integration]/*, hash*/) { - if (integration.iconImage) { - const style = `background-image:url(${integration.iconImage});background-size:36px;`; - return htmlSafe(style); - } -} - -export default helper(integrationLogoStyle); diff --git a/apps/ember-admin/app/helpers/most-recently-updated.js b/apps/ember-admin/app/helpers/most-recently-updated.js deleted file mode 100644 index 4b4662d5a6a..00000000000 --- a/apps/ember-admin/app/helpers/most-recently-updated.js +++ /dev/null @@ -1,19 +0,0 @@ -import moment from 'moment-timezone'; -import {helper} from '@ember/component/helper'; - -export function mostRecentlyUpdated(objs) { - const items = [...(objs || [])]; - - (items || []).sort((a, b) => { - const momentA = moment(a.updatedAtUTC || a.updatedAt || a.updated_at); - const momentB = moment(b.updatedAtUTC || b.updatedAt || b.updated_at); - - return momentB.valueOf() - momentA.valueOf(); - }); - - return items[0] || null; -} - -export default helper(function ([items = []]) { - return mostRecentlyUpdated(items); -}); diff --git a/apps/ember-admin/app/helpers/most-relevant-subscription.js b/apps/ember-admin/app/helpers/most-relevant-subscription.js deleted file mode 100644 index 654888cbb44..00000000000 --- a/apps/ember-admin/app/helpers/most-relevant-subscription.js +++ /dev/null @@ -1,39 +0,0 @@ -import moment from 'moment-timezone'; -import {helper} from '@ember/component/helper'; - -export function mostRelevantSubscription(subs) { - // Ignore comped subscriptions (without id) - const items = [...(subs || []).filter(sub => !!sub.id)]; - - // Find active subscription if any, then sort by latest current_period_end if needed - items.sort((a, b) => { - const isActiveA = ['active', 'trialing', 'unpaid', 'past_due'].includes(a.status); - const isActiveB = ['active', 'trialing', 'unpaid', 'past_due'].includes(b.status); - - // Sort by status, active first - if (isActiveA && !isActiveB) { - return -1; - } else if (!isActiveA && isActiveB) { - return 1; - } - - // Sort by current_period_end, latest first - const endDateA = moment(a.current_period_end); - const endDateB = moment(b.current_period_end); - - if (!endDateA.isValid()) { - return 1; - } else if (!endDateB.isValid()) { - return -1; - } - - return endDateB.valueOf() - endDateA.valueOf(); - }); - - return items[0] || null; -} - -export default helper(function ([items = []]) { - return mostRelevantSubscription(items); -}); - diff --git a/apps/ember-admin/app/helpers/set-has.js b/apps/ember-admin/app/helpers/set-has.js deleted file mode 100644 index c61af2e74ba..00000000000 --- a/apps/ember-admin/app/helpers/set-has.js +++ /dev/null @@ -1,5 +0,0 @@ -import {helper} from '@ember/component/helper'; - -export default helper(function ([set, key]) { - return set.has(key); -}); diff --git a/apps/ember-admin/app/helpers/split-number.js b/apps/ember-admin/app/helpers/split-number.js deleted file mode 100644 index 95b2b6d2c1e..00000000000 --- a/apps/ember-admin/app/helpers/split-number.js +++ /dev/null @@ -1,23 +0,0 @@ -import {formatNumber} from './format-number'; -import {helper} from '@ember/component/helper'; -import {htmlSafe} from '@ember/template'; - -export function splitNumber([number, previousNumber]) { - if (number === undefined || previousNumber === undefined) { - return 0; - } - - const formattedNewNumber = formatNumber(number); - const formattedOldNumber = formatNumber(previousNumber); - - const oldChars = formattedOldNumber.split('').map(char => `${char}`).join(''); - - const newChars = formattedNewNumber.split('').map(char => `${char}`).join(''); - - return htmlSafe(` -
    ${oldChars}
    -
    ${newChars}
    - `); -} - -export default helper(splitNumber); diff --git a/apps/ember-admin/app/helpers/toggle-feature.js b/apps/ember-admin/app/helpers/toggle-feature.js deleted file mode 100644 index aa99d63af0a..00000000000 --- a/apps/ember-admin/app/helpers/toggle-feature.js +++ /dev/null @@ -1,13 +0,0 @@ -import Helper from '@ember/component/helper'; -import {inject as service} from '@ember/service'; - -export default class ToggleFeature extends Helper { - @service feature; - - compute([featureFlag]) { - return () => { - const flag = !!this.feature.get(featureFlag); - this.feature.set(featureFlag, !flag); - }; - } -} diff --git a/apps/ember-admin/app/helpers/ui-btn-span.js b/apps/ember-admin/app/helpers/ui-btn-span.js deleted file mode 100644 index 7c53c168020..00000000000 --- a/apps/ember-admin/app/helpers/ui-btn-span.js +++ /dev/null @@ -1,8 +0,0 @@ -import {btnStyles} from './ui-btn'; -import {helper} from '@ember/component/helper'; - -export function uiBtnSpan([style], hash) { - return btnStyles(Object.assign({}, {style}, hash)).span; -} - -export default helper(uiBtnSpan); diff --git a/apps/ember-admin/app/helpers/ui-btn.js b/apps/ember-admin/app/helpers/ui-btn.js deleted file mode 100644 index 14299905b46..00000000000 --- a/apps/ember-admin/app/helpers/ui-btn.js +++ /dev/null @@ -1,60 +0,0 @@ -import {helper} from '@ember/component/helper'; - -export function btnStyles(options = {}) { - let button = 'dib midgrey btn-base br3 ba b--lightgrey-l1 pointer glow'; - let span = 'f8 fw5 tracked-2 dib pt0 pb0 tc'; - - // Set style - if (options.style) { - switch (options.style) { - case 'outline-white': - case 'outline-white--s': - button = 'bg-transparent dib white btn-base br3 ba b--white-60 pointer highlight-white'; - span = 'f8 fw5 tracked-2 dib pt0 pb0 tc'; - break; - case 'outline-blue': - case 'outline-blue--s': - button = 'bg-transparent dib blue btn-base br3 ba b--lightgrey pointer glow'; - span = 'f8 fw5 tracked-2 dib pt0 pb0 tc'; - break; - case 'blue': - case 'blue--s': - button = 'dib bw0 white br3 btn-base btn-blue pointer'; - span = 'f8 fw5 tracked-2 dib pt0 pb0 tc'; - break; - case 'green': - case 'green--s': - button = 'dib bw0 white br3 btn-base btn-green pointer'; - span = 'f8 fw5 tracked-2 dib pt0 pb0 tc'; - break; - case 'red': - case 'red--s': - button = 'dib bw0 white br3 btn-base btn-red pointer'; - span = 'f8 fw5 tracked-2 dib pt0 pb0 tc'; - break; - } - - // Style ending with '--s' means small button - if (options.style.substr(options.style.length - 3) === '--s') { - button = `${button} btn-small`; - } - } - - if (options.class) { - button = `${button} ${options.class}`; - } - - button = `sans-serif ${button} flex-shrink-0`; - span = `${span} inline-flex items-center`; - - return { - button: button, - span: span - }; -} - -export function uiBtn([style], hash) { - return btnStyles(Object.assign({}, {style}, hash)).button; -} - -export default helper(uiBtn); diff --git a/apps/ember-admin/app/helpers/ui-text.js b/apps/ember-admin/app/helpers/ui-text.js deleted file mode 100644 index ddd28bc6992..00000000000 --- a/apps/ember-admin/app/helpers/ui-text.js +++ /dev/null @@ -1,42 +0,0 @@ -import {helper} from '@ember/component/helper'; - -export function uiText([style]) { - let cssClass = ''; - - switch (style) { - case 'h1': - cssClass = 'f-subheadline fw7 tracked-3 lh-heading ma0 pa0'; - break; - case 'h2': - cssClass = 'f2 fw6 tracked-2 tracked-3 lh-title ma0 pa0'; - break; - case 'h3': - cssClass = 'f5 fw6 tracked-2 lh-title ma0 pa0'; - break; - case 'h4': - cssClass = 'f7 fw6 tracked-2 lh-copy ma0 pa0'; - break; - case 'h5': - cssClass = 'f8 fw6 tracked-2 lh-copy ma0 pa0'; - break; - case 'h6': - cssClass = 'f-small ttu fw4 tracked-3 lh-copy ma0 pa0'; - break; - case 'tl': - cssClass = 'f6 fw3 lh-copy tracked-1 ma0 pa0'; - break; - case 't': - cssClass = 'f7 fw3 lh-copy tracked-1 ma0 pa0'; - break; - case 'ts': - cssClass = 'f8 fw3 lh-copy tracked-2 ma0 pa0'; - break; - case 'txs': - cssClass = 'f-small fw3 lh-copy tracked-3 ma0 pa0'; - break; - } - - return cssClass; -} - -export default helper(uiText); diff --git a/apps/ember-admin/app/mixins/validation-state.js b/apps/ember-admin/app/mixins/validation-state.js deleted file mode 100644 index fe9493ff926..00000000000 --- a/apps/ember-admin/app/mixins/validation-state.js +++ /dev/null @@ -1,51 +0,0 @@ -import Mixin from '@ember/object/mixin'; -import {A as emberA} from '@ember/array'; -import {isEmpty} from '@ember/utils'; -import {observer} from '@ember/object'; -import {on} from '@ember/object/evented'; -import {run} from '@ember/runloop'; - -/** - * Adds `success` or `error` classes to the element based on the passed - * in `DS.Errors` object, the `property` to inspect, and an array of - * validated property names in `hasValidated` - */ -export default Mixin.create({ - - errors: null, - property: '', - hasValidated: emberA(), - - hasError: false, - - setHasError() { - const property = this.property; - const errors = this.errors; - const hasValidated = this.hasValidated; - - // if we aren't looking at a specific property we always want an error class - if (!property && errors && !errors.get('isEmpty')) { - this.set('hasError', true); - return; - } - - // If we haven't yet validated this field, there is no validation class needed - if (!hasValidated || !hasValidated.includes(property)) { - this.set('hasError', false); - return; - } - - if (errors && !isEmpty(errors.errorsFor(property))) { - this.set('hasError', true); - return; - } - - this.set('hasError', false); - }, - - // eslint-disable-next-line ghost/ember/no-observers - hasErrorObserver: on('init', observer('errors.[]', 'property', 'hasValidated.[]', function () { - run.once(this, 'setHasError'); - })) - -}); diff --git a/apps/ember-admin/app/modifiers/movable.js b/apps/ember-admin/app/modifiers/movable.js deleted file mode 100644 index cc503be35e5..00000000000 --- a/apps/ember-admin/app/modifiers/movable.js +++ /dev/null @@ -1,237 +0,0 @@ -import Modifier from 'ember-modifier'; -import {action} from '@ember/object'; -import {guidFor} from '@ember/object/internals'; -import {registerDestructor} from '@ember/destroyable'; -import {inject as service} from '@ember/service'; - -export default class MovableModifier extends Modifier { - @service dropdown; - - moveThreshold = 3; - - active = false; - currentX = undefined; - currentY = undefined; - initialX = undefined; - initialY = undefined; - xOffset = 0; - yOffset = 0; - - constructor(owner, args) { - super(owner, args); - registerDestructor(this, this.cleanup); - } - - // Lifecycle hooks --------------------------------------------------------- - - modify(element, positional, named) { - if (!this.didSetup) { - this.elem = element; - this.addStartEventListeners(); - - if (named.adjustOnResize) { - this._resizeObserver = new ResizeObserver(() => { - if (this.currentX === undefined || this.currentY === undefined) { - return; - } - - const {x, y} = named.adjustOnResize(element, {x: this.currentX, y: this.currentY}); - - if (x === this.currentX && y === this.currentY) { - return; - } - - this.currentX = x; - this.initialX = x; - this.xOffset = x; - - this.currentY = y; - this.initialY = y; - this.yOffset = y; - - this.setTranslate(x, y); - }); - this._resizeObserver.observe(element); - } - - this.didSetup = true; - } - } - - cleanup = () => { - this.removeEventListeners(); - this.removeResizeObserver(); - this.enableSelection(); - }; - - // Custom methods ----------------------------------------------------------- - - addStartEventListeners() { - this.elem.addEventListener('touchstart', this.dragStart, false); - this.elem.addEventListener('mousedown', this.dragStart, false); - } - - removeStartEventListeners() { - this.elem.removeEventListener('touchstart', this.dragStart, false); - this.elem.removeEventListener('mousedown', this.dragStart, false); - } - - addActiveEventListeners() { - window.addEventListener('touchend', this.dragEnd, {capture: true, passive: false}); - window.addEventListener('touchmove', this.drag, {capture: true, passive: false}); - window.addEventListener('mouseup', this.dragEnd, {capture: true, passive: false}); - window.addEventListener('mousemove', this.drag, {capture: true, passive: false}); - } - - removeActiveEventListeners() { - window.removeEventListener('touchend', this.dragEnd, {capture: true, passive: false}); - window.removeEventListener('touchmove', this.drag, {capture: true, passive: false}); - window.removeEventListener('mouseup', this.dragEnd, {capture: true, passive: false}); - window.removeEventListener('mousemove', this.drag, {capture: true, passive: false}); - - // Removing this immediately results in the click event behind re-enabled in the same - // event loop meaning that it doesn't have the desired effect when dragging out of the canvas. - // Putting in the next tick stops the immediate click event firing when finishing drag - setTimeout(() => { - window.removeEventListener('click', this.cancelClick, {capture: true, passive: false}); - }, 1); - } - - removeEventListeners() { - this.removeStartEventListeners(); - this.removeActiveEventListeners(); - } - - removeResizeObserver() { - this._resizeObserver?.disconnect(); - } - - @action - dragStart(e) { - if (e.type === 'touchstart' || e.button === 0) { - if (e.type === 'touchstart') { - this.initialX = e.touches[0].clientX - this.xOffset; - this.initialY = e.touches[0].clientY - this.yOffset; - } else { - this.initialX = e.clientX - this.xOffset; - this.initialY = e.clientY - this.yOffset; - } - - for (const elem of (e.path || e.composedPath())) { - if (elem.matches('input, .ember-basic-dropdown-trigger')) { - break; - } - - if (elem === this.elem) { - this.addActiveEventListeners(); - break; - } - } - } - } - - @action - drag(e) { - e.preventDefault(); - - let eventX, eventY; - - if (e.type === 'touchmove') { - eventX = e.touches[0].clientX; - eventY = e.touches[0].clientY; - } else { - eventX = e.clientX; - eventY = e.clientY; - } - - if (!this.active) { - if ( - Math.abs(Math.abs(this.initialX - eventX) - Math.abs(this.xOffset)) > this.moveThreshold || - Math.abs(Math.abs(this.initialY - eventY) - Math.abs(this.yOffset)) > this.moveThreshold - ) { - this.dropdown.closeDropdowns(); - this.disableScroll(); - this.disableSelection(); - this.disablePointerEvents(); - this.active = true; - } - } - - if (this.active) { - this.currentX = eventX - this.initialX; - this.currentY = eventY - this.initialY; - this.xOffset = this.currentX; - this.yOffset = this.currentY; - - this.setTranslate(this.currentX, this.currentY); - } - } - - @action - dragEnd(e) { - e.preventDefault(); - e.stopPropagation(); - - this.active = false; - - this.initialX = this.currentX; - this.initialY = this.currentY; - - this.removeActiveEventListeners(); - this.enableScroll(); - this.enableSelection(); - - // timeout required so immediate events blocked until the dragEnd has fully realised - setTimeout(() => { - this.enablePointerEvents(); - }, 5); - } - - @action - cancelClick(e) { - e.preventDefault(); - e.stopPropagation(); - } - - setTranslate(xPos, yPos) { - this.elem.style.transform = `translate3d(${xPos}px, ${yPos}px, 0)`; - } - - disableScroll() { - this.originalOverflow = this.elem.style.overflow; - this.elem.style.overflow = 'hidden'; - } - - enableScroll() { - this.elem.style.overflow = this.originalOverflow; - } - - disableSelection() { - window.getSelection().removeAllRanges(); - - const stylesheet = document.createElement('style'); - stylesheet.id = `stylesheet-${guidFor(this)}`; - - document.head.appendChild(stylesheet); - - stylesheet.sheet.insertRule('* { user-select: none !important; }', 0); - } - - enableSelection() { - const stylesheet = document.getElementById(`stylesheet-${guidFor(this)}`); - stylesheet?.remove(); - } - - // disabling pointer events prevents inputs being activated when drag finishes, - // preventing clicks stops any event handlers that may otherwise result in the - // movable element being closed when the drag finishes - disablePointerEvents() { - this.elem.style.pointerEvents = 'none'; - window.addEventListener('click', this.cancelClick, {capture: true, passive: false}); - } - - enablePointerEvents() { - this.elem.style.pointerEvents = ''; - window.removeEventListener('click', this.cancelClick, {capture: true, passive: false}); - } -} diff --git a/apps/ember-admin/app/modifiers/scroll-into-view.js b/apps/ember-admin/app/modifiers/scroll-into-view.js deleted file mode 100644 index 81f88795c11..00000000000 --- a/apps/ember-admin/app/modifiers/scroll-into-view.js +++ /dev/null @@ -1,54 +0,0 @@ -import getScrollParent from 'ghost-admin/utils/get-scroll-parent'; -import {modifier} from 'ember-modifier'; - -export default modifier((element, [shouldScroll = true], {offset = 0, useViewport = true}) => { - if (shouldScroll) { - const scrollParent = getScrollParent(element); - - // scrolls so the element is visible on-screen - if (useViewport) { - const elementRect = element.getBoundingClientRect(); - const scrollParentRect = scrollParent.getBoundingClientRect(); - - // TODO: ensure scroll parent is visible? - - const isOffTop = elementRect.top < 0; - const isOffBottom = elementRect.bottom > scrollParentRect.bottom; - - if (isOffTop) { - // TODO: implement me - } - - if (isOffBottom) { - let adjustment = Math.abs(scrollParentRect.bottom - elementRect.bottom); - - // keep top on screen - if (elementRect.top - adjustment < offset) { - const readjustment = Math.abs(elementRect.top - adjustment - (offset * 2)); - adjustment -= readjustment; - } - - const top = scrollParent.scrollTop + adjustment + offset; - - scrollParent.scrollTo({top, behavior: 'smooth'}); - } - } - - // scrolls so the element is visible inside of the scroll parent's viewport, - // may not result in element being visible on-screen if scroll parent is cut off - if (!useViewport) { - const isOffTop = element.offsetTop < scrollParent.scrollTop; - const isOffBottom = scrollParent.scrollTop + scrollParent.offsetHeight < element.offsetTop + element.offsetHeight; - - if (isOffTop) { - const top = element.offsetTop - offset; - scrollParent.scrollTo({top, behavior: 'smooth'}); - } - - if (isOffBottom) { - const top = element.offsetTop - scrollParent.offsetHeight + element.offsetHeight + offset; - scrollParent.scrollTo({top, behavior: 'smooth'}); - } - } - } -}, {eager: false}); diff --git a/apps/ember-admin/app/routes/application.js b/apps/ember-admin/app/routes/application.js index c0ec9863380..fd7f7db0bf3 100644 --- a/apps/ember-admin/app/routes/application.js +++ b/apps/ember-admin/app/routes/application.js @@ -117,6 +117,7 @@ window.ReactDOM = ReactDOM; export default Route.extend(ShortcutsRoute, { ajax: service(), configManager: service(), + feature: service(), ghostPaths: service(), notifications: service(), router: service(), @@ -243,6 +244,11 @@ export default Route.extend(ShortcutsRoute, { }, openSearchModal() { + // React owns Cmd-K search while this flag is on + if (this.feature.globalSearchReact) { + return; + } + // Don't open the search modal if the sidebar is hidden // e.g. in the editor or settings screens if (this.ui.isFullScreen) { diff --git a/apps/ember-admin/app/services/data-cache.js b/apps/ember-admin/app/services/data-cache.js deleted file mode 100644 index cbc729d1f47..00000000000 --- a/apps/ember-admin/app/services/data-cache.js +++ /dev/null @@ -1,37 +0,0 @@ -import Service from '@ember/service'; - -const ONE_MINUTE = 1 * 60 * 1000; - -export default class DataCacheService extends Service { - cache = {}; - timeouts = {}; - - get(key) { - return this.cache[key]; - } - - set(key, data, lifetime = ONE_MINUTE) { - this.cache[key] = data; - - this.timeouts[key] = window.setTimeout(() => { - delete this.cache[key]; - delete this.timeouts[key]; - }, lifetime); - - return this.cache[key]; - } - - clear() { - this._clearAllTimeouts(); - this.cache = {}; - this.timeouts = {}; - } - - willDestroy() { - this._clearAllTimeouts(); - } - - _clearAllTimeouts() { - Object.keys(this.timeouts).forEach(key => window.clearTimeout(this.timeouts[key])); - } -} diff --git a/apps/ember-admin/app/services/state-bridge.js b/apps/ember-admin/app/services/state-bridge.js index 3c405a69afe..179726bb4be 100644 --- a/apps/ember-admin/app/services/state-bridge.js +++ b/apps/ember-admin/app/services/state-bridge.js @@ -29,6 +29,7 @@ const emberDataTypeMapping = { }; export default class StateBridgeService extends Service.extend(Evented) { + @service billing; @service customViews; @service feature; @service membersUtils; @@ -268,6 +269,13 @@ export default class StateBridgeService extends Service.extend(Evented) { this.trigger('openGiftLinkModal', {id, resource}); } + // A billing search result for the billing route already showing is a no-op + // Ember transition, so React hands the sub-route to the billing app directly + @action + navigateToBillingSubRoute(subRoute) { + this.billing.navigateToSubRoute(subRoute); + } + get sidebarVisible() { // Sidebar is visible when NOT in fullscreen mode return !this.ui.isFullScreen; diff --git a/apps/ember-admin/app/transforms/moment-date.js b/apps/ember-admin/app/transforms/moment-date.js deleted file mode 100644 index 0f78626913d..00000000000 --- a/apps/ember-admin/app/transforms/moment-date.js +++ /dev/null @@ -1,18 +0,0 @@ -import Transform from '@ember-data/serializer/transform'; -import moment from 'moment-timezone'; - -export default class MomentDate extends Transform { - deserialize(serialized) { - if (serialized) { - return moment(serialized); - } - return serialized; - } - - serialize(deserialized) { - if (deserialized) { - return moment(deserialized).toDate(); - } - return deserialized; - } -} diff --git a/apps/ember-admin/app/transforms/visibility-string.js b/apps/ember-admin/app/transforms/visibility-string.js deleted file mode 100644 index f1f75bab65e..00000000000 --- a/apps/ember-admin/app/transforms/visibility-string.js +++ /dev/null @@ -1,29 +0,0 @@ -import Transform from '@ember-data/serializer/transform'; - -// post visibility supports `'members'` and `'paid'` as special-case options -// but that doesn't map well for options in our token select inputs so we -// expand/convert them here to make usage elsewhere easier - -export default class VisibilityString extends Transform { - deserialize(serialized) { - if (serialized === 'members') { - return 'status:free,status:-free'; - } - if (serialized === 'paid') { - return 'status:-free'; - } - - return serialized; - } - - serialize(deserialized) { - if (deserialized === 'status:free,status:-free') { - return 'members'; - } - if (deserialized === 'status:-free') { - return 'paid'; - } - - return deserialized; - } -} diff --git a/apps/ember-admin/app/utils/merge-stats-by-date.js b/apps/ember-admin/app/utils/merge-stats-by-date.js deleted file mode 100644 index bdd881f8976..00000000000 --- a/apps/ember-admin/app/utils/merge-stats-by-date.js +++ /dev/null @@ -1,26 +0,0 @@ -export default function mergeStatsByDate(list) { - const reducedStatsByDate = list.reduce((acc, current) => { - const currentDate = current.date; - - if (!acc[currentDate]) { - acc[currentDate] = { - date: currentDate, - count: 0, - positiveDelta: 0, - negativeDelta: 0, - signups: 0, - cancellations: 0 - }; - } - - acc[currentDate].count += current.count; - acc[currentDate].positiveDelta += current.positive_delta; - acc[currentDate].negativeDelta += current.negative_delta; - acc[currentDate].signups += current.signups; - acc[currentDate].cancellations += current.cancellations; - - return acc; - }, {}); - - return Object.values(reducedStatsByDate); -} diff --git a/apps/ember-admin/app/utils/password-generator.js b/apps/ember-admin/app/utils/password-generator.js deleted file mode 100644 index 62b8f662522..00000000000 --- a/apps/ember-admin/app/utils/password-generator.js +++ /dev/null @@ -1,86 +0,0 @@ -/* - * copied from https://github.com/bermi/password-generator - * Copyright(c) 2011-2015 Bermi Ferrer - * MIT Licensed - */ - -// copied from the NPM module because the switch away from polyfilling `global` -// via webpack in ember-auto-import resulted in an error finding 'crypto' -// Module not found: Error: Can't resolve 'crypto' - -const vowel = /[aeiou]$/i; -const consonant = /[bcdfghjklmnpqrstvwxyz]$/i; - -function rand(min, max) { - var key, value, arr = new Uint8Array(max); - getRandomValues(arr); - for (key in arr) { - if (Object.prototype.hasOwnProperty.call(arr, key)) { - value = arr[key]; - if (value > min && value < max) { - return value; - } - } - } - return rand(min, max); -} - -function getRandomValues(buf) { - window.crypto.getRandomValues(buf); -} - -export default function generatePassword(length, memorable, pattern, prefix) { - var char = '', n, i, validChars = []; - if (length === null || typeof (length) === 'undefined') { - length = 10; - } - if (memorable === null || typeof (memorable) === 'undefined') { - memorable = true; - } - if (pattern === null || typeof (pattern) === 'undefined') { - pattern = /\w/; - } - if (prefix === null || typeof (prefix) === 'undefined') { - prefix = ''; - } - - // Non memorable passwords will pick characters from a pre-generated - // list of characters - if (!memorable) { - for (i = 33; 126 > i; i += 1) { - char = String.fromCharCode(i); - if (char.match(pattern)) { - validChars.push(char); - } - } - - if (!validChars.length) { - throw new Error('Could not find characters that match the ' + - 'password pattern ' + pattern + '. Patterns must match individual ' + - 'characters, not the password as a whole.'); - } - } - - while (prefix.length < length) { - if (memorable) { - if (prefix.match(consonant)) { - pattern = vowel; - } else { - pattern = consonant; - } - n = rand(33, 126); - char = String.fromCharCode(n); - } else { - char = validChars[rand(0, validChars.length)]; - } - - if (memorable) { - char = char.toLowerCase(); - } - if (char.match(pattern)) { - prefix = '' + prefix + char; - } - } - - return prefix; -} diff --git a/apps/ember-admin/app/validators/subscriber.js b/apps/ember-admin/app/validators/subscriber.js deleted file mode 100644 index ece4cc97a42..00000000000 --- a/apps/ember-admin/app/validators/subscriber.js +++ /dev/null @@ -1,25 +0,0 @@ -import BaseValidator from './base'; -import validator from 'validator'; -import {isBlank} from '@ember/utils'; - -export default BaseValidator.create({ - properties: ['email'], - - email(model) { - const email = model.email; - - if (isBlank(email)) { - model.errors.add('email', 'Please enter an email.'); - model.hasValidated.pushObject('email'); - this.invalidate(); - } else if (!validator.isEmail(email)) { - model.errors.add('email', 'Invalid email.'); - model.hasValidated.pushObject('email'); - this.invalidate(); - } else if (!validator.isLength(email, {max: 191})) { - model.errors.add('email', 'Email is too long'); - model.hasValidated.pushObject('email'); - this.invalidate(); - } - } -}); diff --git a/apps/ember-admin/tests/acceptance/search-test.js b/apps/ember-admin/tests/acceptance/search-test.js index 22f63af4939..ae94b7ae83d 100644 --- a/apps/ember-admin/tests/acceptance/search-test.js +++ b/apps/ember-admin/tests/acceptance/search-test.js @@ -2,6 +2,7 @@ import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd'; import {authenticateSession} from 'ember-simple-auth/test-support'; import {click, currentURL, find, findAll, settled, triggerKeyEvent, visit} from '@ember/test-helpers'; import {describe, it} from 'mocha'; +import {enableLabsFlag} from '../helpers/labs-flag'; import {expect} from 'chai'; import {getPosts} from '../../mirage/config/posts'; import {setupApplicationTest} from 'ember-mocha'; @@ -611,4 +612,26 @@ describe('Acceptance: Search', function () { expect(currentURL()).to.equal(`/settings/staff/${testData.user.slug}`); }); }); + + describe('with the globalSearchReact flag', function () { + const hooks = setupApplicationTest(); + setupMirage(hooks); + + beforeEach(async function () { + this.server.loadFixtures(); + enableLabsFlag(this.server, 'globalSearchReact'); + createTestData(this.server); + await authenticateSession(); + }); + + it('leaves the Ctrl/Cmd+K shortcut action to React', async function () { + await visit('/analytics'); + assertSearchShortcutRegistered(this.owner); + + this.owner.lookup('route:application').send('openSearchModal'); + await settled(); + + assertSearchModalClosed(); + }); + }); }); diff --git a/apps/ember-admin/tests/integration/components/gh-date-picker-test.js b/apps/ember-admin/tests/integration/components/gh-date-picker-test.js deleted file mode 100644 index 93e658c5d0f..00000000000 --- a/apps/ember-admin/tests/integration/components/gh-date-picker-test.js +++ /dev/null @@ -1,363 +0,0 @@ -// import Service from '@ember/service'; -import hbs from 'htmlbars-inline-precompile'; -import moment from 'moment-timezone'; -import sinon from 'sinon'; -import {blur, click, fillIn, find, focus, render, triggerKeyEvent, typeIn} from '@ember/test-helpers'; -import {datepickerSelect} from 'ember-power-datepicker/test-support'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {setupRenderingTest} from 'ember-mocha'; - -// class SettingsStub extends Service { -// timezone = 'Etc/UTC'; - -// get(key) { -// if (key === 'timezone') { -// return this.timezone; -// } -// } -// } - -describe('Integration: Component: gh-date-picker', function () { - setupRenderingTest(); - let clock; - - // beforeEach(async function () { - // this.owner.register('service:settings', SettingsStub); - // }); - - afterEach(function () { - clock?.restore(); - }); - - it('renders', async function () { - await render(hbs``); - expect(find('[data-test-date-picker-trigger]'), 'datepicker trigger').to.exist; - expect(find('[data-test-date-picker-input]'), 'datepicker input').to.exist; - }); - - it('defaults to now when @value is empty', async function () { - clock = sinon.useFakeTimers({ - now: moment('2022-02-22 22:22:22.000').toDate() - }); - - await render(hbs``); - expect(find('[data-test-date-picker-input]'), 'date input').to.have.value('2022-02-22'); - }); - - it('shows passed in @value value', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - - await render(hbs``); - expect(find('[data-test-date-picker-input]'), 'date input').to.have.value('2022-02-22'); - }); - - it('updates date via input blur', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - - const changeSpy = sinon.spy(); - this.set('onChange', changeSpy); - - await render(hbs``); - await fillIn('[data-test-date-picker-input]', '2022-02-28'); - await blur('[data-test-date-picker-input]'); - - expect(changeSpy.callCount).to.equal(1); - expect(changeSpy.firstCall.args[0]).to.be.an.instanceof(Date); - expect(changeSpy.firstCall.args[0].toISOString()).to.equal(moment('2022-02-28T00:00:00.000').toISOString()); - }); - - it('updates date via input Enter keydown', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - - const changeSpy = sinon.spy(); - this.set('onChange', changeSpy); - - await render(hbs``); - await fillIn('[data-test-date-picker-input]', '2022-02-28'); - await triggerKeyEvent('[data-test-date-picker-input]', 'keydown', 'Enter'); - - expect(changeSpy.callCount).to.equal(1); - expect(changeSpy.firstCall.args[0]).to.be.an.instanceof(Date); - expect(changeSpy.firstCall.args[0].toISOString()).to.equal(moment('2022-02-28T00:00:00.000').toISOString()); - }); - - it('updates date via datepicker selection', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - - const onChange = (newDate) => { - this.set('date', newDate); - }; - const changeSpy = sinon.spy(onChange); - this.set('onChange', changeSpy); - - await render(hbs``); - await datepickerSelect('[data-test-date-picker-trigger]', moment('2022-02-27T13:00:00.000').toDate()); - - expect(find('[data-test-date-picker-input]')).to.have.value('2022-02-27'); - - expect(changeSpy.callCount).to.equal(1); - expect(changeSpy.firstCall.args[0]).to.be.an.instanceof(Date); - expect(changeSpy.firstCall.args[0].toISOString()).to.equal(moment('2022-02-27T00:00:00.000').toISOString()); - }); - - it('updates when @value is changed externally', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - - await render(hbs``); - expect(find('[data-test-date-picker-input]'), 'date input').to.have.value('2022-02-22'); - - this.set('date', moment('2022-02-28 10:00:00.000')).toDate(); - - expect(find('[data-test-date-picker-input]'), 'date input').to.have.value('2022-02-28'); - }); - - it('updates when @value is changed externally when we have a scratch date', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - - await render(hbs``); - expect(find('[data-test-date-picker-input]'), 'date input').to.have.value('2022-02-22'); - - await fillIn('[data-test-date-picker-input]', '2022-02-27'); - expect(find('[data-test-date-picker-input]'), 'date input').to.have.value('2022-02-27'); - - this.set('date', moment('2022-02-28 10:00:00.000')).toDate(); - expect(find('[data-test-date-picker-input]'), 'date input').to.have.value('2022-02-28'); - }); - - it('calls @onInput on input events', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - - const inputSpy = sinon.spy(); - this.set('onInput', inputSpy); - - await render(hbs``); - await typeIn('[data-test-date-picker-input]', 'lo'); - - expect(inputSpy.callCount).to.equal(2); - expect(inputSpy.firstCall.args[0]).to.be.instanceOf(Event); - }); - - it('calls @onKeydown on input keydown events', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - - const keydownSpy = sinon.spy(); - this.set('onKeydown', keydownSpy); - - await render(hbs``); - await typeIn('[data-test-date-picker-input]', 'lo'); - - expect(keydownSpy.callCount).to.equal(2); - expect(keydownSpy.firstCall.args[0]).to.be.instanceOf(Event); - }); - - it('calls @onBlur on input blur events', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - - const blurSpy = sinon.spy(); - this.set('onBlur', blurSpy); - - await render(hbs``); - await focus('[data-test-date-picker-input]'); - await blur('[data-test-date-picker-input]'); - - expect(blurSpy.callCount).to.equal(1); - expect(blurSpy.firstCall.args[0]).to.be.instanceOf(Event); - }); - - it('resets input value on Escape', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - - const changeSpy = sinon.spy(); - this.set('onChange', changeSpy); - - await render(hbs``); - await fillIn('[data-test-date-picker-input]', '2022-02-28'); - await triggerKeyEvent('[data-test-date-picker-input]', 'keydown', 'Escape'); - - expect(changeSpy.callCount).to.equal(0); - expect(find('[data-test-date-picker-input]')).to.have.value('2022-02-22'); - }); - - it('handles invalid date input', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - - const changeSpy = sinon.spy(); - this.set('onChange', changeSpy); - - const errorSpy = sinon.spy(); - this.set('onError', errorSpy); - - await render(hbs``); - await fillIn('[data-test-date-picker-input]', '2022-02-31'); - await blur('[data-test-date-picker-input]'); - - expect(find('[data-test-date-picker-error]')).to.have.text('Invalid date'); - - expect(changeSpy.callCount, '@onChange call count').to.equal(0); - expect(errorSpy.callCount, '@onError call count').to.equal(1); - expect(errorSpy.firstCall.args[0]).to.be.instanceof(Error); - expect(errorSpy.firstCall.args[0].message).to.equal('Invalid date'); - }); - - it('handles invalid date format input', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - - const changeSpy = sinon.spy(); - this.set('onChange', changeSpy); - - const errorSpy = sinon.spy(); - this.set('onError', errorSpy); - - await render(hbs``); - await fillIn('[data-test-date-picker-input]', 'narp'); - await blur('[data-test-date-picker-input]'); - - expect(find('[data-test-date-picker-error]')).to.contain.text('Date must be YYYY-MM-DD'); - - expect(changeSpy.callCount, '@onChange call count').to.equal(0); - expect(errorSpy.callCount, '@onError call count').to.equal(1); - expect(errorSpy.firstCall.args[0]).to.be.instanceof(Error); - expect(errorSpy.firstCall.args[0].message).to.contain('Date must be YYYY-MM-DD'); - }); - - it('clears error on internal change to valid', async function () { - await render(hbs``); - await fillIn('[data-test-date-picker-input]', 'narp'); - await blur('[data-test-date-picker-input]'); - - expect(find('[data-test-date-picker-error]')).to.exist; - - await fillIn('[data-test-date-picker-input]', '2022-02-22'); - await blur('[data-test-date-picker-input]'); - - expect(find('[data-test-date-picker-error]')).to.not.exist; - }); - - it('clears error on external @value change to valid', async function () { - await render(hbs``); - await fillIn('[data-test-date-picker-input]', 'narp'); - await blur('[data-test-date-picker-input]'); - - expect(find('[data-test-date-picker-error]')).to.exist; - - this.set('date', moment('2022-02-22')); - - expect(find('[data-test-date-picker-error]')).to.not.exist; - }); - - it('clears error on reset', async function () { - await render(hbs``); - await fillIn('[data-test-date-picker-input]', 'narp'); - await blur('[data-test-date-picker-input]'); - - expect(find('[data-test-date-picker-error]')).to.exist; - - await triggerKeyEvent('[data-test-date-picker-input]', 'keydown', 'Escape'); - - expect(find('[data-test-date-picker-error]')).to.not.exist; - }); - - describe('min/max', function () { - it('disables datepicker dates outside of range', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - this.set('minDate', moment('2022-02-11 12:00:00.000').toDate()); - this.set('maxDate', moment('2022-02-24 12:00:00.000').toDate()); - - await render(hbs``); - await click('[data-test-date-picker-trigger]'); - - expect(find('[data-date="2022-02-10"]')).to.have.attribute('disabled'); - expect(find('[data-date="2022-02-25"]')).to.have.attribute('disabled'); - }); - - it('errors when date input is earlier than min', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - this.set('minDate', moment('2022-02-11 12:00:00.000').toDate()); - - const changeSpy = sinon.spy(); - this.set('onChange', changeSpy); - - const errorSpy = sinon.spy(); - this.set('onError', errorSpy); - - await render(hbs``); - await fillIn('[data-test-date-picker-input]', '2022-02-10'); - await blur('[data-test-date-picker-input]'); - - expect(find('[data-test-date-picker-error]')).to.have.text('Must be on or after 2022-02-11'); - - expect(changeSpy.callCount, '@onChange call count').to.equal(0); - expect(errorSpy.callCount, '@onError call count').to.equal(1); - expect(errorSpy.firstCall.args[0]).to.be.instanceof(Error); - expect(errorSpy.firstCall.args[0].message).to.equal('Must be on or after 2022-02-11'); - expect(errorSpy.firstCall.args[0].date).to.be.equal('2022-02-10'); - }); - - it('allows for min date error override', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - this.set('minDate', moment('2022-02-11 12:00:00.000').toDate()); - - await render(hbs``); - - await fillIn('[data-test-date-picker-input]', '2022-02-10'); - await blur('[data-test-date-picker-input]'); - - expect(find('[data-test-date-picker-error]')).to.have.text('Must be in the future'); - }); - - it('errors when date input is later than max', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - this.set('maxDate', moment('2022-02-25 12:00:00.000').toDate()); - - const changeSpy = sinon.spy(); - this.set('onChange', changeSpy); - - const errorSpy = sinon.spy(); - this.set('onError', errorSpy); - - await render(hbs``); - await fillIn('[data-test-date-picker-input]', '2022-02-28'); - await blur('[data-test-date-picker-input]'); - - expect(find('[data-test-date-picker-error]')).to.have.text('Must be on or before 2022-02-25'); - - expect(changeSpy.callCount, '@onChange call count').to.equal(0); - expect(errorSpy.callCount, '@onError call count').to.equal(1); - expect(errorSpy.firstCall.args[0]).to.be.instanceof(Error); - expect(errorSpy.firstCall.args[0].message).to.equal('Must be on or before 2022-02-25'); - expect(errorSpy.firstCall.args[0].date).to.be.equal('2022-02-28'); - }); - - it('allows for max date error override', async function () { - this.set('date', moment('2022-02-22 22:22:22.000')).toDate(); - this.set('maxDate', moment('2022-02-25 12:00:00.000').toDate()); - - await render(hbs``); - - await fillIn('[data-test-date-picker-input]', '2022-02-28'); - await blur('[data-test-date-picker-input]'); - - expect(find('[data-test-date-picker-error]')).to.have.text('Please choose a past date.'); - }); - }); - - describe('block invocation', function () { - it('exposes Nav and Days components', async function () { - clock = sinon.useFakeTimers({ - now: moment('2022-02-02 22:22:22.000').toDate() - }); - - this.set('date', moment('2022-02-02 22:22:22.000')).toDate(); - this.set('maxDate', moment('2022-02-05 12:00:00.000').toDate()); - - await render(hbs``); - - await click('[data-test-date-picker-trigger]'); - - // calendar is rendered with the right maxDate value curried - expect(find('[data-date="2022-02-10"]')).to.have.attribute('disabled'); - expect(find('[data-date="2022-02-25"]')).to.have.attribute('disabled'); - }); - }); -}); diff --git a/apps/ember-admin/tests/integration/components/gh-members-segment-select-test.js b/apps/ember-admin/tests/integration/components/gh-members-segment-select-test.js deleted file mode 100644 index d4021f38a97..00000000000 --- a/apps/ember-admin/tests/integration/components/gh-members-segment-select-test.js +++ /dev/null @@ -1,191 +0,0 @@ -import hbs from 'htmlbars-inline-precompile'; -import {clickTrigger, selectChoose, typeInSearch} from 'ember-power-select/test-support/helpers'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {find, findAll, render, settled, waitUntil} from '@ember/test-helpers'; -import {setupRenderingTest} from 'ember-mocha'; -import {startMirage} from 'ghost-admin/initializers/ember-cli-mirage'; - -describe('Integration: Component: gh-members-segment-select', function () { - setupRenderingTest(); - - let server; - - beforeEach(function () { - server = startMirage(); - server.create('user'); - }); - - afterEach(function () { - server.shutdown(); - }); - - it('renders tier and label options', async function () { - server.create('tier', {name: 'Gold Tier', slug: 'gold', type: 'paid', active: true}); - server.create('label', {name: 'VIP', slug: 'vip'}); - - this.set('segment', null); - this.set('onChange', () => {}); - - await render(hbs``); - await clickTrigger(); - await waitUntil(() => findAll('.ember-power-select-option').length >= 4); - - const options = findAll('.ember-power-select-option'); - const optionTexts = options.map(o => o.textContent.trim()); - - expect(optionTexts).to.include('Free members'); - expect(optionTexts).to.include('Paid members'); - expect(optionTexts).to.include('Gold Tier'); - expect(optionTexts).to.include('VIP'); - }); - - it('selects options and fires onChange', async function () { - server.create('tier', {name: 'Silver', slug: 'silver', type: 'paid', active: true}); - server.create('label', {name: 'Newsletter', slug: 'newsletter'}); - - let lastSegment; - this.set('segment', null); - this.set('onChange', (segment) => { - lastSegment = segment; - this.set('segment', segment); - }); - - await render(hbs``); - await waitUntil(() => findAll('.ember-power-select-multiple-option').length === 0 && !document.querySelector('.ember-power-select-trigger--active')); - - // Select "Free members" - await selectChoose('.ember-power-select-trigger', 'Free members'); - - expect(lastSegment).to.equal('status:free'); - }); - - it('shows selected options on render', async function () { - server.create('tier', {name: 'Bronze', slug: 'bronze', type: 'paid', active: true}); - server.create('label', {name: 'Premium', slug: 'premium'}); - - this.set('segment', 'status:free'); - this.set('onChange', () => {}); - - await render(hbs``); - await waitUntil(() => findAll('.ember-power-select-multiple-option').length > 0); - - const tokens = findAll('.ember-power-select-multiple-option'); - expect(tokens.length).to.equal(1); - expect(tokens[0].textContent).to.include('Free members'); - }); - - it('loads labels with infinite scroll', async function () { - server.create('tier', {name: 'Basic', slug: 'basic', type: 'paid', active: true}); - - // Create 105 labels (more than 1 page of 100) - server.createList('label', 105); - - this.set('segment', null); - this.set('onChange', () => {}); - - await render(hbs``); - await clickTrigger(); - await waitUntil(() => findAll('.ember-power-select-option').length > 0); - - // Verify first page loaded - const labelRequests = server.pretender.handledRequests.filter(r => r.url.includes('/labels')); - expect(labelRequests.length).to.be.at.least(1); - - // Scroll to bottom to trigger second page - const optionsContent = find('.ember-power-select-options'); - optionsContent.scrollTo({top: optionsContent.scrollHeight}); - await settled(); - - // Wait for second page request - await waitUntil(() => server.pretender.handledRequests.some(r => r.url.includes('/labels') && r.queryParams.page === '2')); - }); - - it('uses client-side search when all labels loaded (single page)', async function () { - // Create fewer than 100 labels (fits in 1 page) - server.create('label', {name: 'Alpha', slug: 'alpha'}); - server.create('label', {name: 'Beta', slug: 'beta'}); - - this.set('segment', null); - this.set('onChange', () => {}); - - await render(hbs``); - await clickTrigger(); - await waitUntil(() => findAll('.ember-power-select-option').length > 0); - - const requestCountBefore = server.pretender.handledRequests.length; - - await typeInSearch('Alpha'); - - // No additional API requests should be made for client-side search - expect(server.pretender.handledRequests.length).to.equal(requestCountBefore); - }); - - it('uses server-side search when not all labels loaded', async function () { - // Create more than 100 labels to trigger multi-page - server.createList('label', 105); - - this.set('segment', null); - this.set('onChange', () => {}); - - await render(hbs``); - await clickTrigger(); - await waitUntil(() => findAll('.ember-power-select-option').length > 0); - - await typeInSearch('Label 50'); - - // Wait for a search request with filter param containing the typed term - await waitUntil(() => server.pretender.handledRequests.some(r => r.url.includes('/labels') && r.queryParams.filter && r.queryParams.filter.includes('name:~') && r.queryParams.filter.includes('Label 50'))); - }); - - it('selected search result from outside paginated set resolves as token', async function () { - // Create 100 labels to fill page 1, then one extra that won't be loaded initially - server.createList('label', 100); - server.create('label', {name: 'Unique Outlier', slug: 'unique-outlier'}); - - let lastSegment; - this.set('segment', null); - this.set('onChange', (segment) => { - lastSegment = segment; - this.set('segment', segment); - }); - - await render(hbs``); - await clickTrigger(); - await waitUntil(() => findAll('.ember-power-select-option').length > 0); - - // Search for the label that's NOT in the initial paginated set - await typeInSearch('Unique Outlier'); - await waitUntil(() => server.pretender.handledRequests.some(r => r.url.includes('/labels') && r.queryParams.filter && r.queryParams.filter.includes('Unique Outlier'))); - await waitUntil(() => findAll('.ember-power-select-option').length > 0); - - // Select the search result - await selectChoose('.ember-power-select-trigger', 'Unique Outlier'); - - expect(lastSegment).to.include('label:unique-outlier'); - - // The label should appear as a selected token — this proves it was - // registered with labelsManager via addLabel so selectedOptions resolves it - const tokens = findAll('.ember-power-select-multiple-option'); - expect(tokens.length).to.equal(1); - expect(tokens[0].textContent).to.include('Unique Outlier'); - }); - - it('respects hideLabels', async function () { - server.create('label', {name: 'Hidden', slug: 'hidden'}); - server.create('tier', {name: 'Visible', slug: 'visible', type: 'paid', active: true}); - - this.set('segment', null); - this.set('onChange', () => {}); - - await render(hbs``); - await clickTrigger(); - await waitUntil(() => findAll('.ember-power-select-option').length > 0); - - const options = findAll('.ember-power-select-option'); - const optionTexts = options.map(o => o.textContent.trim()); - - expect(optionTexts).to.not.include('Hidden'); - expect(optionTexts).to.include('Free members'); - }); -}); diff --git a/apps/ember-admin/tests/integration/helpers/activity-feed-fetcher-test.js b/apps/ember-admin/tests/integration/helpers/activity-feed-fetcher-test.js deleted file mode 100644 index 3427cb1c46d..00000000000 --- a/apps/ember-admin/tests/integration/helpers/activity-feed-fetcher-test.js +++ /dev/null @@ -1,139 +0,0 @@ -import hbs from 'htmlbars-inline-precompile'; -import moment from 'moment-timezone'; -import {click, find, findAll, render} from '@ember/test-helpers'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {setupMirage} from 'ember-cli-mirage/test-support'; -import {setupRenderingTest} from 'ember-mocha'; - -describe('Integration: Helper: activity-feed-fetcher-test', function () { - const hooks = setupRenderingTest(); - setupMirage(hooks); - - it('can fetch events', async function () { - this.server.createList('member-activity-event', 10, {createdAt: moment('2022-11-01 12:00:00').format('YYYY-MM-DD HH:mm:ss')}); - - await render(hbs` - {{#let (activity-feed-fetcher pageSize="2") as |eventsFetcher|}} -
    {{eventsFetcher.shownEvents}}
    -
    {{eventsFetcher.totalEvents}}
    - {{/let}} - `); - - expect(find('.shown-events')).to.have.trimmed.text('2'); - expect(find('.total-events')).to.have.trimmed.text('10'); - }); - - it('can update total/shown amount of events ', async function () { - this.server.createList('member-activity-event', 5, {createdAt: moment('2022-11-01 12:00:00').format('YYYY-MM-DD HH:mm:ss')}); - - await render(hbs` - {{#let (activity-feed-fetcher pageSize="2") as |eventsFetcher|}} - - - -
    {{eventsFetcher.shownEvents}}
    -
    {{eventsFetcher.totalEvents}}
    - {{/let}} - `); - - const totalEvents = find('.total-events'); - const shownEvents = find('.shown-events'); - - expect(shownEvents).to.have.trimmed.text('2'); - expect(totalEvents).to.have.trimmed.text('5'); - - // nothing should change if user tries to load previous page on the first one - await click('.previous'); - expect(shownEvents).to.have.trimmed.text('2'); - expect(totalEvents).to.have.trimmed.text('5'); - - // go to the last page - await click('.next'); - await click('.next'); - expect(shownEvents).to.have.trimmed.text('5'); - expect(totalEvents).to.have.trimmed.text('5'); - - // nothing should change if user tries to load next page on the last one - await click('.next'); - expect(shownEvents).to.have.trimmed.text('5'); - expect(totalEvents).to.have.trimmed.text('5'); - - await click('.previous'); - expect(shownEvents).to.have.trimmed.text('4'); - expect(totalEvents).to.have.trimmed.text('5'); - }); - - it('can update data for each page', async function () { - this.server.createList('member-activity-event', 5, {createdAt: moment('2022-11-01 12:00:00').format('YYYY-MM-DD HH:mm:ss')}); - // create event in future to make sure that user don't get events after current date - this.server.create('member-activity-event', {createdAt: moment().add(1, 'd').format('YYYY-MM-DD HH:mm:ss')}); - - await render(hbs` - {{#let (activity-feed-fetcher pageSize="2") as |eventsFetcher|}} - - - - {{#each eventsFetcher.data as |event|}} -
    {{event.data.id}}
    - {{/each}} - {{/let}} - `); - - expect(findAll('.event-id').length).to.equal(2); - expect(findAll('.event-id')[0]).to.have.trimmed.text('5'); - - await click('.next'); - expect(findAll('.event-id').length).to.equal(2); - expect(findAll('.event-id')[0]).to.have.trimmed.text('3'); - - await click('.next'); - expect(findAll('.event-id').length).to.equal(1); - expect(findAll('.event-id')[0]).to.have.trimmed.text('1'); - - // nothing should change if user tries to load next page on the last one - await click('.next'); - expect(findAll('.event-id').length).to.equal(1); - expect(findAll('.event-id')[0]).to.have.trimmed.text('1'); - - await click('.previous'); - expect(findAll('.event-id').length).to.equal(2); - expect(findAll('.event-id')[0]).to.have.trimmed.text('3'); - - await click('.previous'); - expect(findAll('.event-id').length).to.equal(2); - expect(findAll('.event-id')[0]).to.have.trimmed.text('5'); - - // nothing should change if user tries to load previous page on the first one - await click('.previous'); - expect(findAll('.event-id').length).to.equal(2); - expect(findAll('.event-id')[0]).to.have.trimmed.text('5'); - }); - - it('change error state and show error message if fetch was unsuccessful ', async function () { - this.server.createList('member-activity-event', 10, {createdAt: moment('2022-11-01 12:00:00').format('YYYY-MM-DD HH:mm:ss')}); - - await render(hbs` - {{#let (activity-feed-fetcher pageSize="2") as |eventsFetcher|}} - - -
    {{eventsFetcher.isError}}
    -
    {{eventsFetcher.errorMessage}}
    - {{/let}} - `); - - expect(find('.error')).to.have.trimmed.text('false'); - expect(find('.error-message')).to.have.trimmed.text(''); - - this.server.get( - '/members/events', - () => ({errors: [{message: 'Error message'}]}), - 500 - ); - - await click('.next'); - - expect(find('.error')).to.have.trimmed.text('true'); - expect(find('.error-message')).to.have.trimmed.text('Error message'); - }); -}); diff --git a/apps/ember-admin/tests/unit/helpers/gh-count-characters-test.js b/apps/ember-admin/tests/unit/helpers/gh-count-characters-test.js deleted file mode 100644 index 7777047a021..00000000000 --- a/apps/ember-admin/tests/unit/helpers/gh-count-characters-test.js +++ /dev/null @@ -1,37 +0,0 @@ -import {countCharacters} from 'ghost-admin/helpers/gh-count-characters'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; - -describe('Unit: Helper: gh-count-characters', function () { - const defaultStyle = 'color: rgb(69, 195, 46);'; - const errorStyle = 'color: rgb(240, 82, 48);'; - - it('counts remaining chars', function () { - const result = countCharacters(['test']); - expect(result.string) - .to.equal(`196`); - }); - - it('warns when nearing limit', function () { - const result = countCharacters([Array(195 + 1).join('x')]); - expect(result.string) - .to.equal(`5`); - }); - - it('indicates too many chars', function () { - const result = countCharacters([Array(205 + 1).join('x')]); - expect(result.string) - .to.equal(`-5`); - }); - - it('counts multibyte correctly', function () { - let result = countCharacters(['💩']); - expect(result.string) - .to.equal(`199`); - - // emoji + modifier is still two chars - result = countCharacters(['💃🏻']); - expect(result.string) - .to.equal(`198`); - }); -}); diff --git a/apps/ember-admin/tests/unit/helpers/most-recently-updated-test.js b/apps/ember-admin/tests/unit/helpers/most-recently-updated-test.js deleted file mode 100644 index bb1320ae24f..00000000000 --- a/apps/ember-admin/tests/unit/helpers/most-recently-updated-test.js +++ /dev/null @@ -1,64 +0,0 @@ -import moment from 'moment-timezone'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {mostRecentlyUpdated} from 'ghost-admin/helpers/most-recently-updated'; - -describe('Unit: Helper: most-recently-updated', function () { - it('returns most recent - updatedAtUTC', function () { - const a = {updatedAtUTC: moment.utc('2022-03-04 16:10')}; - const b = {updatedAtUTC: moment.utc('2022-03-03 16:10')}; - const c = {updatedAtUTC: moment.utc('2022-03-04 16:20')}; - - const subs = [a, b, c]; - - expect(mostRecentlyUpdated(subs)).to.equal(c); - }); - - it('returns most recent - updatedAt', function () { - const a = {updatedAt: moment('2022-03-04 16:10')}; - const b = {updatedAt: moment('2022-03-05 16:10')}; - const c = {updatedAt: moment('2022-03-04 16:20')}; - - const subs = [a, b, c]; - - expect(mostRecentlyUpdated(subs)).to.equal(b); - }); - - it('returns most recent - updated_at', function () { - const a = {updated_at: '2022-03-04 16:10'}; - const b = {updated_at: '2022-03-03 16:10'}; - const c = {updated_at: '2022-03-04 16:20'}; - - const subs = [a, b, c]; - - expect(mostRecentlyUpdated(subs)).to.equal(c); - }); - - it('handles a single-element array', function () { - const a = {updated_at: '2022-02-22'}; - - expect(mostRecentlyUpdated([a])).to.equal(a); - }); - - it('handles null', function () { - expect(mostRecentlyUpdated(null)).to.equal(null); - }); - - it('handles empty array', function () { - expect(mostRecentlyUpdated([])).to.equal(null); - }); - - it('does not modify original array', function () { - const a = {updated_at: '2022-03-04 16:10'}; - const b = {updated_at: '2022-03-03 16:10'}; - const c = {updated_at: '2022-03-04 16:20'}; - - const subs = [a, b, c]; - - mostRecentlyUpdated(subs); - - expect(subs[0]).to.equal(a); - expect(subs[1]).to.equal(b); - expect(subs[2]).to.equal(c); - }); -}); diff --git a/apps/ember-admin/tests/unit/helpers/most-relevant-subscription-test.js b/apps/ember-admin/tests/unit/helpers/most-relevant-subscription-test.js deleted file mode 100644 index e38f11155c7..00000000000 --- a/apps/ember-admin/tests/unit/helpers/most-relevant-subscription-test.js +++ /dev/null @@ -1,72 +0,0 @@ -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {mostRelevantSubscription} from 'ghost-admin/helpers/most-relevant-subscription'; - -describe('Unit: Helper: most-relevant-subscription', function () { - it('returns active subscriptions first', function () { - const active = {id: 'a', status: 'active', current_period_end: '2022-03-04 16:10'}; - const canceled = {id: 'b', status: 'canceled', current_period_end: '2022-03-04 16:10'}; - - const subs = [active, canceled]; - - expect(mostRelevantSubscription(subs)).to.equal(active); - }); - - it('returns the subscription with the latest current_period_end', function () { - const older = {id: 'a', status: 'active', current_period_end: '2022-03-04 16:10'}; - const latest = {id: 'b', status: 'active', current_period_end: '2022-03-04 16:20'}; - - const subs = [older, latest]; - - expect(mostRelevantSubscription(subs)).to.equal(latest); - }); - - it('ignores comped subscriptions', function () { - const normal = {id: 'a', status: 'active', current_period_end: '2022-03-04 16:10'}; - const comped = {id: null, status: 'active', current_period_end: '2022-03-04 16:20'}; - - const subs = [normal, comped]; - - expect(mostRelevantSubscription(subs)).to.equal(normal); - }); - - it('handles null or invalid dates', function () { - const a = {id: 'a', status: 'active', current_period_end: '2022-03-04 16:10'}; - const b = {id: 'b', status: 'active', current_period_end: '2022-03-04 16:20'}; - const c = {id: 'c', status: 'active', current_period_end: ''}; - const d = {id: 'd', status: 'active', current_period_end: null}; - const e = {id: 'e', status: 'active', current_period_end: 'string'}; - - const subs = [a, b, c, d, e]; - - expect(mostRelevantSubscription(subs)).to.equal(b); - }); - - it('handles a single-element array', function () { - const a = {id: 'a', current_period_end: '2022-02-22'}; - - expect(mostRelevantSubscription([a])).to.equal(a); - }); - - it('handles null', function () { - expect(mostRelevantSubscription(null)).to.equal(null); - }); - - it('handles empty array', function () { - expect(mostRelevantSubscription([])).to.equal(null); - }); - - it('does not modify original array', function () { - const a = {id: 'a', status: 'active', current_period_end: '2022-03-04 16:10'}; - const b = {id: 'b', status: 'canceled', current_period_end: '2022-03-04 16:10'}; - const c = {id: null, status: 'active', current_period_end: '2022-03-04 16:10'}; - - const subs = [a, b, c]; - - mostRelevantSubscription(subs); - - expect(subs[0]).to.equal(a); - expect(subs[1]).to.equal(b); - expect(subs[2]).to.equal(c); - }); -}); diff --git a/apps/ember-admin/tests/unit/services/state-bridge-test.js b/apps/ember-admin/tests/unit/services/state-bridge-test.js index 7bd3b944fcf..284b15feb81 100644 --- a/apps/ember-admin/tests/unit/services/state-bridge-test.js +++ b/apps/ember-admin/tests/unit/services/state-bridge-test.js @@ -55,6 +55,17 @@ describe('Unit: Service: state-bridge', function () { sinon.restore(); }); + describe('#navigateToBillingSubRoute', function () { + it('hands the sub-route to the billing app', function () { + const billing = this.owner.lookup('service:billing'); + sinon.stub(billing, 'navigateToSubRoute'); + + service.navigateToBillingSubRoute('/plans'); + + expect(billing.navigateToSubRoute.calledOnceWithExactly('/plans')).to.be.true; + }); + }); + describe('#isFeatureEnabled', function () { it('does not claim route ownership before Labs settings load', function () { settings.settingsModel = null; diff --git a/apps/ember-admin/tests/unit/utils/merge-stats-by-date-test.js b/apps/ember-admin/tests/unit/utils/merge-stats-by-date-test.js deleted file mode 100644 index 62480d478a7..00000000000 --- a/apps/ember-admin/tests/unit/utils/merge-stats-by-date-test.js +++ /dev/null @@ -1,184 +0,0 @@ -import mergeStatsByDate from 'ghost-admin/utils/merge-stats-by-date'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; - -const STATS_DATA = [ - { - date: '2024-06-22', - tier: '111111111111111111111111', - cadence: 'month', - positive_delta: 0, - negative_delta: 0, - signups: 0, - cancellations: 0, - count: 456 - }, - { - date: '2024-06-22', - tier: '111111111111111111111111', - cadence: 'year', - positive_delta: 1, - negative_delta: 1, - signups: 0, - cancellations: 0, - count: 1354 - }, - { - date: '2024-06-23', - tier: '111111111111111111111111', - cadence: 'month', - positive_delta: 0, - negative_delta: 0, - signups: 0, - cancellations: 0, - count: 456 - }, - { - date: '2024-06-23', - tier: '111111111111111111111111', - cadence: 'year', - positive_delta: 1, - negative_delta: 1, - signups: 0, - cancellations: 0, - count: 1354 - }, - { - date: '2024-06-23', - tier: '111111111111111111111113', - cadence: 'year', - positive_delta: 0, - negative_delta: 0, - signups: 0, - cancellations: 0, - count: 400 - }, - { - date: '2024-06-24', - tier: '111111111111111111111111', - cadence: 'year', - positive_delta: 3, - negative_delta: 2, - signups: 1, - cancellations: 0, - count: 1355 - }, - { - date: '2024-06-24', - tier: '111111111111111111111113', - cadence: 'year', - positive_delta: 2, - negative_delta: 1, - signups: 2, - cancellations: 1, - count: 401 - }, - { - date: '2024-06-24', - tier: '111111111111111111111112', - cadence: 'year', - positive_delta: 1, - negative_delta: 0, - signups: 1, - cancellations: 0, - count: 55 - }, - { - date: '2024-06-25', - tier: '111111111111111111111111', - cadence: 'month', - positive_delta: 0, - negative_delta: 1, - signups: 0, - cancellations: 1, - count: 455 - }, - { - date: '2024-06-25', - tier: '111111111111111111111111', - cadence: 'year', - positive_delta: 2, - negative_delta: 5, - signups: 1, - cancellations: 4, - count: 1352 - }, - { - date: '2024-06-25', - tier: '111111111111111111111113', - cadence: 'year', - positive_delta: 1, - negative_delta: 2, - signups: 1, - cancellations: 2, - count: 400 - }, - { - date: '2024-06-26', - tier: '111111111111111111111111', - cadence: 'year', - positive_delta: 2, - negative_delta: 2, - signups: 0, - cancellations: 0, - count: 1352 - }, - { - date: '2024-06-26', - tier: '111111111111111111111113', - cadence: 'year', - positive_delta: 0, - negative_delta: 0, - signups: 0, - cancellations: 0, - count: 400 - } -]; - -describe('mergeStatsByDate', function () { - it('merges stats as expected', function () { - const result = mergeStatsByDate(STATS_DATA); - expect(result).to.deep.equal([ - { - date: '2024-06-22', - count: 1810, - positiveDelta: 1, - negativeDelta: 1, - signups: 0, - cancellations: 0 - }, - { - date: '2024-06-23', - count: 2210, - positiveDelta: 1, - negativeDelta: 1, - signups: 0, - cancellations: 0 - }, - { - date: '2024-06-24', - count: 1811, - positiveDelta: 6, - negativeDelta: 3, - signups: 4, - cancellations: 1 - }, - { - date: '2024-06-25', - count: 2207, - positiveDelta: 3, - negativeDelta: 8, - signups: 2, - cancellations: 7 - }, - { - date: '2024-06-26', - count: 1752, - positiveDelta: 2, - negativeDelta: 2, - signups: 0, - cancellations: 0 - } - ]); - }); -}); diff --git a/docs/contributing/test-data.md b/docs/contributing/test-data.md index ef7c21d9355..c72c8e63b27 100644 --- a/docs/contributing/test-data.md +++ b/docs/contributing/test-data.md @@ -36,7 +36,8 @@ do not cover the scenario: ```bash docker exec ghost-dev bash -c \ - 'cd /home/ghost/ghost/core && node index.js generate-data \ + 'cd /home/ghost/ghost/core && node --conditions=source --import=tsx index.js \ + generate-data \ --clear-database --quantities members:10000,posts:500 --seed 123' ``` diff --git a/e2e/helpers/pages/admin/posts/post/index.ts b/e2e/helpers/pages/admin/posts/post/index.ts index 45d9856593c..99d625f77cd 100644 --- a/e2e/helpers/pages/admin/posts/post/index.ts +++ b/e2e/helpers/pages/admin/posts/post/index.ts @@ -2,3 +2,7 @@ export { PageEditorPage, PostEditorPage } from './post-editor-page'; export { PostPreviewModal } from './post-preview-modal'; export { DesktopPreviewFrame, EmailPreviewFrame } from './post-preview-frames'; export type { PostPreviewImplementation } from './post-preview-frames'; +export { EditorHeader } from './post-editor-header'; +export { FeatureImage } from './post-feature-image'; +export { PostSettingsSidebar } from './post-settings-sidebar'; +export type { PostSettingsSectionId } from './post-settings-sidebar'; diff --git a/e2e/helpers/pages/admin/posts/post/post-editor-header.ts b/e2e/helpers/pages/admin/posts/post/post-editor-header.ts new file mode 100644 index 00000000000..1c328c877ef --- /dev/null +++ b/e2e/helpers/pages/admin/posts/post/post-editor-header.ts @@ -0,0 +1,69 @@ +import { Locator, Page } from '@playwright/test'; +import { + editorHeaderActions, + editorPreviewButton, + editorPublishButton, + editorPublishInputsError, + editorSaveButton, + editorSaveErrorBanner, + editorScheduleCountdown, + editorStatus, + editorUnpublishButton, + editorUnscheduleButton, + editorUpdateButton, + postsBackLink, +} from '@tryghost/test-data/selectors/editor'; + +/** The React editor's header: the back link, the status line and the actions. */ +export class EditorHeader { + readonly actions: Locator; + readonly backLink: Locator; + readonly status: Locator; + readonly scheduleCountdown: Locator; + readonly saveErrorBanner: Locator; + readonly publishInputsError: Locator; + readonly previewButton: Locator; + readonly publishButton: Locator; + readonly updateButton: Locator; + readonly saveButton: Locator; + readonly unpublishButton: Locator; + readonly unscheduleButton: Locator; + + constructor(page: Page) { + this.actions = page.getByTestId(editorHeaderActions); + this.backLink = page.getByRole('link', { name: postsBackLink, exact: true }); + this.status = page.getByTestId(editorStatus); + this.scheduleCountdown = page.getByTestId(editorScheduleCountdown); + this.saveErrorBanner = page.getByTestId(editorSaveErrorBanner); + this.publishInputsError = page.getByTestId(editorPublishInputsError); + // The publish flow carries a Preview button of its own, so every action is + // scoped to the header. + this.previewButton = this.actionButton(editorPreviewButton); + this.publishButton = this.actionButton(editorPublishButton); + this.updateButton = this.actionButton(editorUpdateButton); + this.saveButton = this.actionButton(editorSaveButton); + this.unpublishButton = this.actionButton(editorUnpublishButton); + this.unscheduleButton = this.actionButton(editorUnscheduleButton); + } + + async preview(): Promise { + await this.previewButton.click(); + } + + /** Opens the publish flow. */ + async publish(): Promise { + await this.publishButton.click(); + } + + async update(): Promise { + await this.updateButton.click(); + } + + async save(): Promise { + await this.saveButton.click(); + } + + private actionButton(name: string): Locator { + return this.actions.getByRole('button', { name, exact: true }); + } +} diff --git a/e2e/helpers/pages/admin/posts/post/post-editor-page.ts b/e2e/helpers/pages/admin/posts/post/post-editor-page.ts index 3df663898f7..1fb2fb7d43e 100644 --- a/e2e/helpers/pages/admin/posts/post/post-editor-page.ts +++ b/e2e/helpers/pages/admin/posts/post/post-editor-page.ts @@ -1,22 +1,16 @@ import { AdminPage } from '@/admin-pages'; import { BasePage } from '@/helpers/pages'; import { DesktopPreviewFrame, PostPreviewModal } from '@/helpers/pages'; +import { EditorHeader } from './post-editor-header'; +import { FeatureImage } from './post-feature-image'; import { Locator, Page } from '@playwright/test'; +import { PostSettingsSidebar } from './post-settings-sidebar'; import { editorBody, editorConflictBanner, - editorHeaderActions, - editorPreviewButton, - editorPublishButton, editorReauthBanner, - editorSaveButton, editorSecondaryInstance, - editorStatus, editorTitleInput, - editorUnpublishButton, - editorUnscheduleButton, - editorUpdateButton, - postsBackLink, publishAtScheduleOption, publishCompleteBookmark, publishConfirm, @@ -124,9 +118,7 @@ class PublishFlow extends BasePage { ? page.getByTestId(publishFlowModal) : page.locator('[data-test-modal="publish-flow"]'); this.publishButton = react - ? page - .getByTestId(editorHeaderActions) - .getByRole('button', { name: editorPublishButton, exact: true }) + ? new EditorHeader(page).publishButton : page.locator('[data-test-button="publish-flow"]').first(); this.optionsStep = react ? page.getByTestId(publishFlowOptions) @@ -293,9 +285,15 @@ export class PostEditorPage extends AdminPage { /** React's update-collision banner. */ readonly conflictBanner: Locator; + /** Ember's settings menu. */ readonly settingsMenu: SettingsMenu; readonly reauthenticateModal: ReAuthenticateModal; + /** React only: the header, the settings sidebar and the feature image. */ + readonly header: EditorHeader; + readonly settings: PostSettingsSidebar; + readonly featureImage: FeatureImage; + constructor( page: Page, { implementation = 'ember' }: { implementation?: PostEditorImplementation } = {}, @@ -305,19 +303,15 @@ export class PostEditorPage extends AdminPage { const react = implementation === 'react'; - const headerActions = page.getByTestId(editorHeaderActions); + this.header = new EditorHeader(page); this.titleInput = react ? page.getByTestId(editorTitleInput) : page.locator('[data-test-editor-title-input]'); // Both chips settle on a "Saved" reading; only the attribute differs. - this.postStatus = react - ? page.getByTestId(editorStatus) - : page.locator('[data-test-editor-post-status]'); - // The publish flow carries a Preview button of its own, so React's is - // scoped to the header. + this.postStatus = react ? this.header.status : page.locator('[data-test-editor-post-status]'); this.previewButton = react - ? headerActions.getByRole('button', { name: editorPreviewButton, exact: true }) + ? this.header.previewButton : page.getByRole('button', { name: 'Preview' }); this.previewModal = new PostPreviewModal(page, { implementation }); this.settingsToggleButton = page.getByTestId(settingsMenuToggle); @@ -334,27 +328,24 @@ export class PostEditorPage extends AdminPage { // Ember labels one primary button Save or Update; React renders whichever // of the two the post's status calls for. this.publishSaveButton = react - ? headerActions.getByRole('button', { - name: new RegExp(`^(${editorSaveButton}|${editorUpdateButton})$`), - }) + ? this.header.saveButton.or(this.header.updateButton) : page.locator('[data-test-button="publish-save"]').first(); this.updateFlowButton = react - ? headerActions.getByRole('button', { - name: new RegExp(`^(${editorUnpublishButton}|${editorUnscheduleButton})$`), - }) + ? this.header.unpublishButton.or(this.header.unscheduleButton) : page.locator('[data-test-button="update-flow"]').first(); this.revertToDraftButton = react ? page.getByTestId(publishRevertToDraft) : page.locator('[data-test-button="revert-to-draft"]'); // Ember's back link carries the inlined arrow icon's title in its // accessible name; React's is a plain link named for the list. - this.backButton = react - ? page.getByRole('link', { name: postsBackLink, exact: true }) - : page.locator('[data-test-breadcrumb]'); + this.backButton = react ? this.header.backLink : page.locator('[data-test-breadcrumb]'); this.settingsMenu = new SettingsMenu(page); this.reauthenticateModal = new ReAuthenticateModal(page); + this.settings = new PostSettingsSidebar(page, this.settingsToggleButton); + this.featureImage = new FeatureImage(page); + this.reauthPrompt = react ? page.getByTestId(editorReauthBanner) : this.reauthenticateModal.modal; diff --git a/e2e/helpers/pages/admin/posts/post/post-feature-image.ts b/e2e/helpers/pages/admin/posts/post/post-feature-image.ts new file mode 100644 index 00000000000..cae27cb8f6c --- /dev/null +++ b/e2e/helpers/pages/admin/posts/post/post-feature-image.ts @@ -0,0 +1,77 @@ +import { Locator, Page } from '@playwright/test'; +import { + addFeatureImageLabel, + editorFeatureImage, + editorFeatureImageCaption, + featureImageAltLabel, + featureImageTkIndicator, + featureImageUnsplashButton, + removeFeatureImageButton, + toggleFeatureImageAltButton, + unsplashSearchHeading, + unsplashSearchModal, +} from '@tryghost/test-data/selectors/editor'; + +/** The React editor's feature image, above the title. */ +export class FeatureImage { + readonly root: Locator; + readonly fileInput: Locator; + readonly unsplashButton: Locator; + /** The Unsplash search, wherever the picker that opened it sits. */ + readonly unsplashSearch: Locator; + /** The search's heading: its wrapper has no box of its own, so this is what shows it open. */ + readonly unsplashHeading: Locator; + readonly removeButton: Locator; + readonly altToggle: Locator; + readonly altInput: Locator; + /** The caption's Koenig content editable. */ + readonly caption: Locator; + readonly tkIndicator: Locator; + + constructor(page: Page) { + this.root = page.getByTestId(editorFeatureImage); + this.fileInput = this.root.getByLabel(addFeatureImageLabel, { exact: true }); + this.unsplashButton = this.root.getByRole('button', { + name: featureImageUnsplashButton, + exact: true, + }); + this.unsplashSearch = page.getByTestId(unsplashSearchModal); + this.unsplashHeading = this.unsplashSearch.getByRole('heading', { + name: unsplashSearchHeading, + exact: true, + }); + this.removeButton = this.root.getByRole('button', { + name: removeFeatureImageButton, + exact: true, + }); + this.altToggle = this.root.getByRole('button', { + name: toggleFeatureImageAltButton, + exact: true, + }); + this.altInput = this.root.getByLabel(featureImageAltLabel, { exact: true }); + this.caption = this.root.getByTestId(editorFeatureImageCaption).getByRole('textbox'); + this.tkIndicator = this.root.getByTestId(featureImageTkIndicator); + } + + async upload(filePath: string): Promise { + await this.fileInput.setInputFiles(filePath); + } + + async openUnsplash(): Promise { + await this.unsplashButton.click(); + await this.unsplashHeading.waitFor({ state: 'visible' }); + } + + async remove(): Promise { + await this.removeButton.click(); + } + + /** The alt input shares the caption's place; the toggle swaps between them. */ + async setAlt(alt: string): Promise { + if (!(await this.altInput.isVisible())) { + await this.altToggle.click(); + } + await this.altInput.fill(alt); + await this.altInput.blur(); + } +} diff --git a/e2e/helpers/pages/admin/posts/post/post-settings-sidebar.ts b/e2e/helpers/pages/admin/posts/post/post-settings-sidebar.ts new file mode 100644 index 00000000000..7f73079bdd8 --- /dev/null +++ b/e2e/helpers/pages/admin/posts/post/post-settings-sidebar.ts @@ -0,0 +1,866 @@ +import { Locator, Page } from '@playwright/test'; +import { + addFacebookImageLabel, + addXImageLabel, + codeInjectionFootLabel, + codeInjectionHeadLabel, + facebookImageUnsplashButton, + postHistoryModal, + postHistoryPreview, + postHistoryPreviewBody, + postHistoryPreviewExcerpt, + postHistoryPreviewFeatureImage, + postHistoryPreviewTitle, + postHistoryRestoreConfirm, + postHistoryRevisionList, + postSettingsSidebar, + removeFacebookImageButton, + removeXImageButton, + restoreRevisionButton, + settingsAuthorChip, + settingsAuthorsError, + settingsAuthorsList, + settingsAuthorsPicker, + settingsCodeInjectionBackButton, + settingsCodeInjectionRow, + settingsDeleteButton, + settingsDeleteCancelButton, + settingsDeleteConfirmButton, + settingsDeleteDialog, + settingsDeleteError, + settingsExcerptInput, + settingsFacebookCardBackButton, + settingsFacebookCardRow, + settingsFacebookDescriptionInput, + settingsFacebookPreview, + settingsFacebookPreviewImage, + settingsFacebookTitleInput, + settingsFeaturedToggle, + settingsKeyboardShortcutsBackButton, + settingsKeyboardShortcutsRow, + settingsLoadError, + settingsMetaDataBackButton, + settingsMetaDataRow, + settingsMetaDescriptionInput, + settingsMetaTitleInput, + settingsPostHistoryButton, + settingsPublishDate, + settingsPublishDateError, + settingsPublishDateNote, + settingsPublishTime, + settingsSerpPreview, + settingsShortcutRow, + settingsShowTitleToggle, + settingsShowTitleWarning, + settingsSlugError, + settingsSlugInput, + settingsSubviewPane, + settingsTagsCreateText, + settingsTagsField, + settingsTagsInput, + settingsTagsList, + settingsTagsToken, + settingsTemplateSelect, + settingsTemplateSlugMatch, + settingsTiersError, + settingsTiersPicker, + settingsUrlPreview, + settingsVisibilitySelect, + settingsXCardBackButton, + settingsXCardRow, + settingsXDescriptionInput, + settingsXImage, + settingsXPreview, + settingsXPreviewImage, + settingsXTitleInput, + showTitleLearnMoreLink, + xImageUnsplashButton, +} from '@tryghost/test-data/selectors/editor'; + +/** The React sidebar's sections, in the order it renders them. */ +export type PostSettingsSectionId = + | 'url' + | 'publish-date' + | 'tags' + | 'access' + | 'excerpt' + | 'authors' + | 'template' + | 'show-title-and-feature-image' + | 'featured' + | 'post-history' + | 'code-injection' + | 'meta-data' + | 'x-card' + | 'facebook-card' + | 'keyboard-shortcuts' + | 'delete'; + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Matches an element whose whole text is `text`, so "News" never matches "Breaking News". */ +function wholeText(text: string): RegExp { + return new RegExp(`^\\s*${escapeRegExp(text)}\\s*$`); +} + +/** Long enough for a section to render, short enough that a missing one fails before the test times out. */ +const PRESENCE_TIMEOUT = 5000; + +async function expectPresent(locator: Locator, message: string): Promise { + try { + await locator.waitFor({ state: 'visible', timeout: PRESENCE_TIMEOUT }); + } catch (error) { + throw new Error(message, { cause: error as Error }); + } +} + +function missingSection(id: PostSettingsSectionId, reason?: string): string { + return `Settings section "${id}" is not in the sidebar${reason ? `: ${reason}` : ''}.`; +} + +/** Radix renders the options in a portal and marks the trigger aria-expanded while they show. */ +async function chooseSelectOption(page: Page, trigger: Locator, label: string): Promise { + await trigger.click(); + const openTrigger = trigger.and(page.locator('[aria-expanded="true"]')); + await openTrigger.waitFor({ state: 'visible' }); + const listboxId = await openTrigger.getAttribute('aria-controls'); + await page + .locator(`[id="${listboxId}"]`) + .getByRole('option', { name: label, exact: true }) + .click(); +} + +/** Every section opens and closes; a section drawn inline in the list has nothing to close. */ +interface SettingsSectionObject { + open(): Promise; + close(): Promise; +} + +/** + * A section drawn inline in the section list. Opening it opens the sidebar and + * brings the section's anchor into view. + */ +abstract class InlineSection implements SettingsSectionObject { + protected readonly page: Page; + protected readonly sidebar: PostSettingsSidebar; + private readonly id: PostSettingsSectionId; + + protected constructor(page: Page, sidebar: PostSettingsSidebar, id: PostSettingsSectionId) { + this.page = page; + this.sidebar = sidebar; + this.id = id; + } + + /** The element that marks where the section sits in the list. */ + protected abstract anchor(): Locator; + + /** Why the list can leave the section out, for the error when it does. */ + protected absentReason(): string | undefined { + return undefined; + } + + async open(): Promise { + await this.sidebar.showSectionList(); + await expectPresent(this.anchor(), missingSection(this.id, this.absentReason())); + await this.anchor().scrollIntoViewIfNeeded(); + } + + async close(): Promise {} +} + +/** A section that is a row in the list and opens a pane over the rest of it. */ +abstract class PaneSection implements SettingsSectionObject { + readonly row: Locator; + readonly backButton: Locator; + readonly pane: Locator; + protected readonly sidebar: PostSettingsSidebar; + private readonly id: PostSettingsSectionId; + + protected constructor( + page: Page, + sidebar: PostSettingsSidebar, + id: PostSettingsSectionId, + { rowLabel, backLabel }: { rowLabel: string; backLabel: string }, + ) { + this.sidebar = sidebar; + this.id = id; + this.row = sidebar.root.getByRole('button', { name: rowLabel, exact: true }); + this.backButton = sidebar.root.getByRole('button', { name: backLabel, exact: true }); + this.pane = page.getByTestId(settingsSubviewPane); + } + + async isOpen(): Promise { + return this.backButton.isVisible(); + } + + /** Leaves any other open pane first: the list is hidden while one is. */ + async open(): Promise { + await this.sidebar.open(); + if (await this.isOpen()) { + return; + } + await this.sidebar.showSectionList(); + await expectPresent(this.row, missingSection(this.id)); + await this.row.click(); + await this.backButton.waitFor({ state: 'visible' }); + } + + /** The back button blurs the focused field first, so it commits the edit. */ + async close(): Promise { + await expectPresent( + this.backButton, + `Settings pane "${this.id}" is not open, so it cannot be closed.`, + ); + await this.backButton.click(); + await this.row.waitFor({ state: 'visible' }); + } +} + +class UrlSection extends InlineSection { + readonly slugInput: Locator; + readonly slugError: Locator; + readonly preview: Locator; + + constructor(page: Page, sidebar: PostSettingsSidebar) { + super(page, sidebar, 'url'); + this.slugInput = page.getByTestId(settingsSlugInput); + this.slugError = page.getByTestId(settingsSlugError); + this.preview = page.getByTestId(settingsUrlPreview); + } + + protected anchor(): Locator { + return this.slugInput; + } + + async setSlug(slug: string): Promise { + await this.slugInput.fill(slug); + await this.slugInput.blur(); + } +} + +class PublishDateSection extends InlineSection { + /** Read-only: the date is chosen from a calendar popover. */ + readonly dateInput: Locator; + readonly timeInput: Locator; + readonly error: Locator; + readonly note: Locator; + + constructor(page: Page, sidebar: PostSettingsSidebar) { + super(page, sidebar, 'publish-date'); + this.dateInput = page.getByTestId(settingsPublishDate); + this.timeInput = page.getByTestId(settingsPublishTime); + this.error = page.getByTestId(settingsPublishDateError); + this.note = page.getByTestId(settingsPublishDateNote); + } + + protected anchor(): Locator { + return this.dateInput; + } + + /** Types `HH:mm` and commits it on blur. */ + async setTime(time: string): Promise { + await this.timeInput.fill(time); + await this.timeInput.blur(); + } +} + +class TagsSection extends InlineSection { + readonly field: Locator; + readonly input: Locator; + readonly list: Locator; + readonly tokens: Locator; + + constructor(page: Page, sidebar: PostSettingsSidebar) { + super(page, sidebar, 'tags'); + this.field = page.getByTestId(settingsTagsField); + this.input = page.getByTestId(settingsTagsInput); + this.list = page.getByTestId(settingsTagsList); + this.tokens = page.getByTestId(settingsTagsToken); + } + + protected anchor(): Locator { + return this.field; + } + + /** An existing tag's row; its accessible name also carries the slug. */ + option(name: string): Locator { + return this.list + .getByRole('option') + .filter({ has: this.page.getByText(name, { exact: true }) }); + } + + createOption(name: string): Locator { + return this.list + .getByRole('option') + .filter({ hasText: settingsTagsCreateText }) + .filter({ hasText: name }); + } + + token(name: string): Locator { + return this.tokens.filter({ hasText: wholeText(name) }); + } + + /** Picks the existing tag of that name, or creates it when the site has none. */ + async add(name: string): Promise { + await this.input.fill(name); + await this.option(name).or(this.createOption(name)).first().click(); + await this.token(name).waitFor({ state: 'visible' }); + } + + /** A chip is removed by clicking it. */ + async remove(name: string): Promise { + await this.token(name).click(); + await this.token(name).waitFor({ state: 'detached' }); + } +} + +class AccessSection extends InlineSection { + readonly visibilitySelect: Locator; + readonly tiersPicker: Locator; + readonly tiersError: Locator; + + constructor(page: Page, sidebar: PostSettingsSidebar) { + super(page, sidebar, 'access'); + this.visibilitySelect = page.getByTestId(settingsVisibilitySelect); + this.tiersPicker = page.getByTestId(settingsTiersPicker); + this.tiersError = page.getByTestId(settingsTiersError); + } + + protected anchor(): Locator { + return this.visibilitySelect; + } + + tier(name: string): Locator { + return this.tiersPicker.getByRole('checkbox', { name, exact: true }); + } + + /** `label` is the option as the select shows it. */ + async setVisibility(label: string): Promise { + await chooseSelectOption(this.page, this.visibilitySelect, label); + } + + /** Ticks a tier in the picker that `Specific tier(s)` shows. */ + async selectTier(name: string): Promise { + await this.tier(name).check(); + } + + async deselectTier(name: string): Promise { + await this.tier(name).uncheck(); + } +} + +class ExcerptSection extends InlineSection { + readonly input: Locator; + + constructor(page: Page, sidebar: PostSettingsSidebar) { + super(page, sidebar, 'excerpt'); + this.input = page.getByTestId(settingsExcerptInput); + } + + protected anchor(): Locator { + return this.input; + } + + async fill(excerpt: string): Promise { + await this.input.fill(excerpt); + await this.input.blur(); + } +} + +class AuthorsSection extends InlineSection { + readonly picker: Locator; + readonly input: Locator; + readonly list: Locator; + readonly chips: Locator; + readonly error: Locator; + + constructor(page: Page, sidebar: PostSettingsSidebar) { + super(page, sidebar, 'authors'); + this.picker = page.getByTestId(settingsAuthorsPicker); + this.input = this.picker.getByRole('combobox'); + this.list = page.getByTestId(settingsAuthorsList); + this.chips = page.getByTestId(settingsAuthorChip); + this.error = page.getByTestId(settingsAuthorsError); + } + + protected anchor(): Locator { + return this.picker; + } + + /** A staff member's row; its accessible name also carries their email. */ + option(name: string): Locator { + return this.list + .getByRole('option') + .filter({ has: this.page.getByText(name, { exact: true }) }); + } + + chip(name: string): Locator { + return this.chips.filter({ hasText: wholeText(name) }); + } + + async add(name: string): Promise { + await this.input.fill(name); + await this.option(name).click(); + await this.chip(name).waitFor({ state: 'visible' }); + } + + /** A chip is removed by clicking it. */ + async remove(name: string): Promise { + await this.chip(name).click(); + await this.chip(name).waitFor({ state: 'detached' }); + } +} + +/** Absent unless the active theme offers custom templates. */ +class TemplateSection extends InlineSection { + readonly select: Locator; + readonly slugMatch: Locator; + + constructor(page: Page, sidebar: PostSettingsSidebar) { + super(page, sidebar, 'template'); + this.select = page.getByTestId(settingsTemplateSelect); + this.slugMatch = page.getByTestId(settingsTemplateSlugMatch); + } + + protected anchor(): Locator { + return this.select; + } + + protected absentReason(): string { + return 'it only renders when the active theme offers custom templates'; + } + + /** `label` is the template as the select shows it. */ + async choose(label: string): Promise { + await chooseSelectOption(this.page, this.select, label); + } +} + +/** Pages only: the post editor leaves this section out. */ +class ShowTitleSection extends InlineSection { + readonly toggle: Locator; + readonly warning: Locator; + readonly learnMoreLink: Locator; + + constructor(page: Page, sidebar: PostSettingsSidebar) { + super(page, sidebar, 'show-title-and-feature-image'); + this.toggle = page.getByTestId(settingsShowTitleToggle); + this.warning = page.getByTestId(settingsShowTitleWarning); + this.learnMoreLink = this.warning.getByRole('link', { name: showTitleLearnMoreLink }); + } + + protected anchor(): Locator { + return this.toggle; + } + + protected absentReason(): string { + return 'only the page editor renders it'; + } +} + +class FeaturedSection extends InlineSection { + readonly toggle: Locator; + + constructor(page: Page, sidebar: PostSettingsSidebar) { + super(page, sidebar, 'featured'); + this.toggle = page.getByTestId(settingsFeaturedToggle); + } + + protected anchor(): Locator { + return this.toggle; + } +} + +/** One saved version in the history list, with the controls it carries. */ +class PostHistoryRevision { + readonly row: Locator; + readonly selectButton: Locator; + readonly restoreButton: Locator; + + constructor(row: Locator) { + this.row = row; + // Named by the version's date, tags and author; the row's only other button is Restore. + this.selectButton = row.getByRole('button', { + name: new RegExp(`^(?!${escapeRegExp(restoreRevisionButton)}$)`), + }); + this.restoreButton = row.getByRole('button', { name: restoreRevisionButton, exact: true }); + } + + async select(): Promise { + await this.selectButton.click(); + } +} + +class PostHistoryModal { + private readonly page: Page; + readonly modal: Locator; + readonly revisions: Locator; + readonly preview: Locator; + readonly previewTitle: Locator; + readonly previewExcerpt: Locator; + readonly previewFeatureImage: Locator; + readonly previewBody: Locator; + readonly restoreConfirm: Locator; + readonly confirmRestoreButton: Locator; + + constructor(page: Page) { + this.page = page; + this.modal = page.getByTestId(postHistoryModal); + this.revisions = page.getByTestId(postHistoryRevisionList).getByRole('listitem'); + this.preview = page.getByTestId(postHistoryPreview); + this.previewTitle = page.getByTestId(postHistoryPreviewTitle); + this.previewExcerpt = page.getByTestId(postHistoryPreviewExcerpt); + this.previewFeatureImage = page.getByTestId(postHistoryPreviewFeatureImage); + this.previewBody = page.getByTestId(postHistoryPreviewBody); + this.restoreConfirm = page.getByTestId(postHistoryRestoreConfirm); + this.confirmRestoreButton = this.restoreConfirm.getByRole('button', { + name: restoreRevisionButton, + exact: true, + }); + } + + /** Newest first: index 0 is the latest version. */ + revision(index: number): PostHistoryRevision { + return new PostHistoryRevision(this.revisions.nth(index)); + } + + /** + * Restores a version and waits for the history to close, which it does once + * the save lands. Only the selected row offers Restore, and never the newest. + */ + async restore(index: number): Promise { + const revision = this.revision(index); + await revision.select(); + await revision.restoreButton.click(); + await this.confirmRestoreButton.click(); + await this.modal.waitFor({ state: 'hidden' }); + } + + async close(): Promise { + await this.page.keyboard.press('Escape'); + await this.modal.waitFor({ state: 'hidden' }); + } +} + +/** A row that opens the history dialog rather than a pane. */ +class PostHistorySection implements SettingsSectionObject { + readonly button: Locator; + readonly history: PostHistoryModal; + private readonly sidebar: PostSettingsSidebar; + + constructor(page: Page, sidebar: PostSettingsSidebar) { + this.sidebar = sidebar; + this.button = page.getByTestId(settingsPostHistoryButton); + this.history = new PostHistoryModal(page); + } + + async open(): Promise { + await this.sidebar.showSectionList(); + await expectPresent( + this.button, + missingSection( + 'post-history', + 'it only renders for a post with a saved lexical body that is not email-only', + ), + ); + await this.button.click(); + await this.history.modal.waitFor({ state: 'visible' }); + } + + async close(): Promise { + await expectPresent(this.history.modal, 'Post history is not open, so it cannot be closed.'); + await this.history.close(); + } +} + +class CodeInjectionPane extends PaneSection { + /** CodeMirror exposes its content as a textbox named by the editor's label. */ + readonly headCode: Locator; + readonly footCode: Locator; + + constructor(page: Page, sidebar: PostSettingsSidebar) { + super(page, sidebar, 'code-injection', { + rowLabel: settingsCodeInjectionRow, + backLabel: settingsCodeInjectionBackButton, + }); + this.headCode = page.getByRole('textbox', { name: new RegExp(`^${codeInjectionHeadLabel}`) }); + this.footCode = page.getByRole('textbox', { name: new RegExp(`^${codeInjectionFootLabel}`) }); + } +} + +class MetaDataPane extends PaneSection { + readonly titleInput: Locator; + readonly descriptionInput: Locator; + readonly searchPreview: Locator; + + constructor(page: Page, sidebar: PostSettingsSidebar) { + super(page, sidebar, 'meta-data', { + rowLabel: settingsMetaDataRow, + backLabel: settingsMetaDataBackButton, + }); + this.titleInput = page.getByTestId(settingsMetaTitleInput); + this.descriptionInput = page.getByTestId(settingsMetaDescriptionInput); + this.searchPreview = page.getByTestId(settingsSerpPreview); + } + + async setTitle(title: string): Promise { + await this.titleInput.fill(title); + await this.titleInput.blur(); + } + + async setDescription(description: string): Promise { + await this.descriptionInput.fill(description); + await this.descriptionInput.blur(); + } +} + +interface SocialCardSelectors { + id: PostSettingsSectionId; + rowLabel: string; + backLabel: string; + titleInput: string; + descriptionInput: string; + preview: string; + previewImage: string; + addImageLabel: string; + unsplashButton: string; + removeImageButton: string; +} + +class SocialCardPane extends PaneSection { + readonly titleInput: Locator; + readonly descriptionInput: Locator; + readonly preview: Locator; + readonly previewImage: Locator; + readonly imageInput: Locator; + readonly unsplashButton: Locator; + readonly removeImageButton: Locator; + + constructor(page: Page, sidebar: PostSettingsSidebar, selectors: SocialCardSelectors) { + super(page, sidebar, selectors.id, { + rowLabel: selectors.rowLabel, + backLabel: selectors.backLabel, + }); + this.titleInput = page.getByTestId(selectors.titleInput); + this.descriptionInput = page.getByTestId(selectors.descriptionInput); + this.preview = page.getByTestId(selectors.preview); + this.previewImage = page.getByTestId(selectors.previewImage); + this.imageInput = this.pane.getByLabel(selectors.addImageLabel, { exact: true }); + this.unsplashButton = this.pane.getByRole('button', { + name: selectors.unsplashButton, + exact: true, + }); + this.removeImageButton = this.pane.getByRole('button', { + name: selectors.removeImageButton, + exact: true, + }); + } + + async setTitle(title: string): Promise { + await this.titleInput.fill(title); + await this.titleInput.blur(); + } + + async setDescription(description: string): Promise { + await this.descriptionInput.fill(description); + await this.descriptionInput.blur(); + } + + async uploadImage(filePath: string): Promise { + await this.imageInput.setInputFiles(filePath); + } +} + +class XCardPane extends SocialCardPane { + readonly image: Locator; + + constructor(page: Page, sidebar: PostSettingsSidebar) { + super(page, sidebar, { + id: 'x-card', + rowLabel: settingsXCardRow, + backLabel: settingsXCardBackButton, + titleInput: settingsXTitleInput, + descriptionInput: settingsXDescriptionInput, + preview: settingsXPreview, + previewImage: settingsXPreviewImage, + addImageLabel: addXImageLabel, + unsplashButton: xImageUnsplashButton, + removeImageButton: removeXImageButton, + }); + this.image = page.getByTestId(settingsXImage); + } +} + +class KeyboardShortcutsPane extends PaneSection { + readonly rows: Locator; + + constructor(page: Page, sidebar: PostSettingsSidebar) { + super(page, sidebar, 'keyboard-shortcuts', { + rowLabel: settingsKeyboardShortcutsRow, + backLabel: settingsKeyboardShortcutsBackButton, + }); + this.rows = page.getByTestId(settingsShortcutRow); + } +} + +class DeleteSection extends InlineSection { + readonly button: Locator; + readonly dialog: Locator; + readonly confirmButton: Locator; + readonly cancelButton: Locator; + readonly error: Locator; + + constructor(page: Page, sidebar: PostSettingsSidebar) { + super(page, sidebar, 'delete'); + this.button = page.getByTestId(settingsDeleteButton); + this.dialog = page.getByTestId(settingsDeleteDialog); + this.confirmButton = this.dialog.getByRole('button', { + name: settingsDeleteConfirmButton, + exact: true, + }); + this.cancelButton = this.dialog.getByRole('button', { + name: settingsDeleteCancelButton, + exact: true, + }); + this.error = page.getByTestId(settingsDeleteError); + } + + protected anchor(): Locator { + return this.button; + } + + /** Confirms the deletion; the editor then leaves for the list. */ + async deletePost(): Promise { + await this.button.click(); + await this.confirmButton.click(); + } +} + +/** The React editor's post settings sidebar. */ +export class PostSettingsSidebar { + readonly root: Locator; + /** A section's failed-browse notice, wherever the sidebar shows one. */ + readonly loadError: Locator; + + readonly url: UrlSection; + readonly publishDate: PublishDateSection; + readonly tags: TagsSection; + readonly access: AccessSection; + readonly excerpt: ExcerptSection; + readonly authors: AuthorsSection; + readonly template: TemplateSection; + readonly showTitle: ShowTitleSection; + readonly featured: FeaturedSection; + readonly postHistory: PostHistorySection; + readonly codeInjection: CodeInjectionPane; + readonly metaData: MetaDataPane; + readonly xCard: XCardPane; + readonly facebookCard: SocialCardPane; + readonly keyboardShortcuts: KeyboardShortcutsPane; + readonly delete: DeleteSection; + + private readonly toggle: Locator; + private readonly sections: Record; + private readonly panes: PaneSection[]; + + /** `toggle` is the header button that shows and hides the sidebar. */ + constructor(page: Page, toggle: Locator) { + this.toggle = toggle; + this.root = page.getByTestId(postSettingsSidebar); + this.loadError = page.getByTestId(settingsLoadError); + + this.url = new UrlSection(page, this); + this.publishDate = new PublishDateSection(page, this); + this.tags = new TagsSection(page, this); + this.access = new AccessSection(page, this); + this.excerpt = new ExcerptSection(page, this); + this.authors = new AuthorsSection(page, this); + this.template = new TemplateSection(page, this); + this.showTitle = new ShowTitleSection(page, this); + this.featured = new FeaturedSection(page, this); + this.postHistory = new PostHistorySection(page, this); + this.codeInjection = new CodeInjectionPane(page, this); + this.metaData = new MetaDataPane(page, this); + this.xCard = new XCardPane(page, this); + this.facebookCard = new SocialCardPane(page, this, { + id: 'facebook-card', + rowLabel: settingsFacebookCardRow, + backLabel: settingsFacebookCardBackButton, + titleInput: settingsFacebookTitleInput, + descriptionInput: settingsFacebookDescriptionInput, + preview: settingsFacebookPreview, + previewImage: settingsFacebookPreviewImage, + addImageLabel: addFacebookImageLabel, + unsplashButton: facebookImageUnsplashButton, + removeImageButton: removeFacebookImageButton, + }); + this.keyboardShortcuts = new KeyboardShortcutsPane(page, this); + this.delete = new DeleteSection(page, this); + + this.sections = { + url: this.url, + 'publish-date': this.publishDate, + tags: this.tags, + access: this.access, + excerpt: this.excerpt, + authors: this.authors, + template: this.template, + 'show-title-and-feature-image': this.showTitle, + featured: this.featured, + 'post-history': this.postHistory, + 'code-injection': this.codeInjection, + 'meta-data': this.metaData, + 'x-card': this.xCard, + 'facebook-card': this.facebookCard, + 'keyboard-shortcuts': this.keyboardShortcuts, + delete: this.delete, + }; + this.panes = [ + this.codeInjection, + this.metaData, + this.xCard, + this.facebookCard, + this.keyboardShortcuts, + ]; + } + + async open(): Promise { + if (!(await this.root.isVisible())) { + await this.toggle.click(); + } + await this.root.waitFor({ state: 'visible' }); + } + + async close(): Promise { + if (await this.root.isVisible()) { + await this.toggle.click(); + } + await this.root.waitFor({ state: 'hidden' }); + } + + /** Opens the sidebar and backs out of any open pane, which hides the list. */ + async showSectionList(): Promise { + await this.open(); + for (const pane of this.panes) { + if (await pane.isOpen()) { + await pane.close(); + return; + } + } + } + + /** + * Opens the sidebar and then the section: its pane or dialog, or its place + * in the list. Fails fast when the list leaves the section out. + */ + async openSection(id: PostSettingsSectionId): Promise { + await this.sections[id].open(); + } + + /** Closes a section's pane or dialog, failing fast when it is not open; an inline section has nothing to close. */ + async closeSection(id: PostSettingsSectionId): Promise { + await this.sections[id].close(); + } +} diff --git a/e2e/helpers/pages/admin/sidebar/sidebar-page.ts b/e2e/helpers/pages/admin/sidebar/sidebar-page.ts index 0fdb921a150..c645a01224a 100644 --- a/e2e/helpers/pages/admin/sidebar/sidebar-page.ts +++ b/e2e/helpers/pages/admin/sidebar/sidebar-page.ts @@ -1,6 +1,7 @@ import * as sidebarSel from '@tryghost/test-data/selectors/sidebar'; import { AdminPage } from '@/admin-pages'; import { Locator, Page } from '@playwright/test'; +import { searchSiteButton } from '@tryghost/test-data/selectors/global-search'; import { whatsNewMenuItem } from '@tryghost/test-data/selectors/whats-new'; export type UserRole = 'Administrator' | 'Editor' | 'Super Editor' | 'Author' | 'Contributor'; @@ -99,7 +100,7 @@ export class SidebarPage extends AdminPage { // the site search control, which only the sidebar contains. this.sidebar = page .getByRole('navigation') - .filter({ has: page.getByRole('button', { name: /Search site/ }) }); + .filter({ has: page.getByRole('button', { name: searchSiteButton }) }); // Container testid — for asserting the sidebar's absence (contributors // get a floating avatar menu instead of the sidebar). this.adminSidebar = page.getByTestId(sidebarSel.adminSidebar); diff --git a/e2e/tests/admin/posts/editor-access-react.test.ts b/e2e/tests/admin/posts/editor-access-react.test.ts new file mode 100644 index 00000000000..98118c1606f --- /dev/null +++ b/e2e/tests/admin/posts/editor-access-react.test.ts @@ -0,0 +1,248 @@ +import { Browser, Page } from '@playwright/test'; +import { + Member, + MemberFactory, + PostFactory, + TierFactory, + buildLexicalParagraph, + createMemberFactory, + createPostFactory, + createTierFactory, +} from '@/data-factory'; +import { PostEditorPage, PostsPage } from '@/admin-pages'; +import { PostPage } from '@/helpers/pages'; +import { expect, test, withIsolatedPage } from '@/helpers/playwright'; +import { signInAsMember } from '@/helpers/playwright/flows/sign-in'; + +/** + * The Access section of the React post editor's settings sidebar, behind the + * `editorReact` Labs flag. Each case follows one visibility choice the whole + * way round: what the writer picks, what the server holds afterwards, and what + * the site shows an anonymous visitor and a member the choice lets in. The + * acceptance tier stops at the request; whether Ghost honours the value is + * only observable here. + */ + +const POSTS_API = '/ghost/api/admin/posts/'; + +/** The options as the Access select shows them. */ +const SPECIFIC_TIERS = 'Specific tier(s)'; +const MEMBERS_ONLY = 'Members only'; + +async function readAccess(page: Page, postId: string) { + const response = await page.request.get(`${POSTS_API}${postId}/?include=tiers`); + expect(response.status()).toBe(200); + const { + posts: [post], + } = await response.json(); + + return { + status: post.status as string, + visibility: post.visibility as string, + slug: post.slug as string, + tierIds: (post.tiers as { id: string }[]).map((tier) => tier.id), + }; +} + +/** Every paid tier the site offers, which is what the tier picker lists. */ +async function readPaidTiers(page: Page): Promise<{ id: string; name: string }[]> { + const response = await page.request.get('/ghost/api/admin/tiers/?filter=type:paid&limit=all'); + expect(response.status()).toBe(200); + const { tiers } = await response.json(); + + return tiers.map((tier: { id: string; name: string }) => ({ + id: tier.id, + name: tier.name, + })); +} + +function waitForPostSave(page: Page, postId: string) { + return page.waitForResponse( + (response) => + response.request().method() === 'PUT' && + response.url().includes(`${POSTS_API}${postId}/`) && + response.status() === 200, + ); +} + +/** Renders the post as a signed-in member and returns the page HTML. */ +async function readPostAsMember( + browser: Browser, + baseURL: string, + member: Member, + slug: string, +): Promise<{ html: string; ctaCount: number }> { + return withIsolatedPage( + browser, + { baseURL, extraHTTPHeaders: { Origin: baseURL } }, + async ({ page: memberPage }) => { + await signInAsMember(memberPage, member); + const sitePost = new PostPage(memberPage); + await sitePost.gotoPost(slug); + await expect(sitePost.articleTitle).toBeVisible(); + + return { + html: await memberPage.content(), + ctaCount: await sitePost.accessCtaContent.count(), + }; + }, + ); +} + +async function startDraft(page: Page, { title, body }: { title: string; body: string }) { + const postsPage = new PostsPage(page); + await postsPage.goto(); + await postsPage.newPostButton.click(); + + const editor = new PostEditorPage(page, { implementation: 'react' }); + await Promise.all([ + page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().includes(POSTS_API) && + response.status() === 201, + ), + editor.createDraft({ title, body }), + ]); + + return { editor, postId: await editor.getPostId() }; +} + +test.describe('Ghost Admin - Post editor access (React)', () => { + // Flag state belongs on the describe — `test.use` inside a test body has no + // effect on the fixtures that test already resolved. + test.use({ labs: { editorReact: true } }); + + let memberFactory: MemberFactory; + let postFactory: PostFactory; + let tierFactory: TierFactory; + + test.beforeEach(async ({ page }) => { + memberFactory = createMemberFactory(page.request); + postFactory = createPostFactory(page.request); + tierFactory = createTierFactory(page.request); + }); + + test.describe('with Stripe connected', () => { + test.use({ stripeEnabled: true }); + + test('new post - Specific tier(s) publishes gated to the ticked tier', async ({ + browser, + baseURL, + page, + }) => { + const stamp = Date.now(); + const title = `react-access-tiers-${stamp}`; + const body = 'Only the ticked tier may read this.'; + const [ticked, other] = await Promise.all([ + tierFactory.create({ + name: `Gold ${stamp}`, + currency: 'usd', + monthly_price: 1000, + yearly_price: 10000, + }), + tierFactory.create({ + name: `Silver ${stamp}`, + currency: 'usd', + monthly_price: 500, + yearly_price: 5000, + }), + ]); + const member = await memberFactory.create({ + email: `gold-${stamp}@example.com`, + status: 'comped', + tiers: [{ id: ticked.id }], + }); + + const { editor, postId } = await startDraft(page, { title, body }); + const { settings } = editor; + + await settings.openSection('access'); + await settings.access.setVisibility(SPECIFIC_TIERS); + // The picker opens with every paid tier ticked, the site's own default + // tier included, so one tier is an untick of all the others + await settings.access.selectTier(ticked.name); + const others = (await readPaidTiers(page)).filter((tier) => tier.id !== ticked.id); + for (const tier of others) { + await settings.access.deselectTier(tier.name); + } + await expect(settings.access.tier(ticked.name)).toBeChecked(); + await expect(settings.access.tier(other.name)).not.toBeChecked(); + + await editor.publishFlow.open(); + await expect(editor.publishFlow.optionsStep).toBeVisible(); + await editor.publishFlow.confirm(); + await expect(editor.publishFlow.completeStep).toBeVisible(); + + const published = await readAccess(page, postId); + expect(published).toMatchObject({ + status: 'published', + visibility: 'tiers', + tierIds: [ticked.id], + }); + + await withIsolatedPage(browser, { baseURL }, async ({ page: visitorPage }) => { + const sitePost = new PostPage(visitorPage); + await sitePost.gotoPost(published.slug); + await expect(sitePost.articleTitle).toHaveText(title); + await expect(sitePost.accessCtaHeading).toContainText(`on the ${ticked.name} tier only`); + expect(await visitorPage.content()).not.toContain(body); + }); + + const asMember = await readPostAsMember(browser, baseURL!, member, published.slug); + expect(asMember.html).toContain(body); + expect(asMember.ctaCount).toBe(0); + }); + }); + + test('published post - Members only gates the body and keeps the title and excerpt', async ({ + browser, + baseURL, + page, + }) => { + const stamp = Date.now(); + const title = `react-access-members-${stamp}`; + const excerpt = `Read on for react-access-members-${stamp}.`; + const body = 'Only members may read this.'; + const created = await postFactory.create({ + title, + status: 'published', + custom_excerpt: excerpt, + lexical: buildLexicalParagraph(body), + }); + expect((await readAccess(page, created.id)).visibility).toBe('public'); + const member = await memberFactory.create({ + email: `free-${stamp}@example.com`, + status: 'free', + }); + + const editor = new PostEditorPage(page, { implementation: 'react' }); + await editor.gotoPost(created.id); + const { settings } = editor; + + await settings.openSection('access'); + await settings.access.setVisibility(MEMBERS_ONLY); + // A published post stages a settings edit until Update, so this click is + // the only save + await Promise.all([waitForPostSave(page, created.id), editor.publishSaveButton.click()]); + + const updated = await readAccess(page, created.id); + expect(updated).toMatchObject({ + status: 'published', + visibility: 'members', + }); + + await withIsolatedPage(browser, { baseURL }, async ({ page: visitorPage }) => { + const sitePost = new PostPage(visitorPage); + await sitePost.gotoPost(created.slug); + await expect(sitePost.articleTitle).toHaveText(title); + await expect(visitorPage.getByText(excerpt)).toBeVisible(); + await expect(sitePost.accessCtaContent).toBeVisible(); + expect(await visitorPage.content()).not.toContain(body); + }); + + const asMember = await readPostAsMember(browser, baseURL!, member, created.slug); + expect(asMember.html).toContain(body); + expect(asMember.ctaCount).toBe(0); + }); +}); diff --git a/e2e/tests/admin/posts/editor-react.test.ts b/e2e/tests/admin/posts/editor-react.test.ts index 64583643ce4..73e8f0c8cdf 100644 --- a/e2e/tests/admin/posts/editor-react.test.ts +++ b/e2e/tests/admin/posts/editor-react.test.ts @@ -77,6 +77,20 @@ async function readPost(page: Page, postId: string) { return post; } +async function readPostSettings(page: Page, postId: string) { + const response = await page.request.get(`/ghost/api/admin/posts/${postId}/?include=tags`); + expect(response.status()).toBe(200); + const { + posts: [post], + } = await response.json(); + + return { + slug: post.slug, + tags: post.tags.map((tag: { name: string }) => tag.name), + metaTitle: post.meta_title, + }; +} + /** * Nothing more is written. A non-event can only be asserted over a window, and * this one has to outlast the 3s autosave debounce plus the request itself — @@ -256,4 +270,40 @@ test.describe('Ghost Admin - Post editor (React)', () => { expect(post.title).toBe(renamed); expect(post.status).toBe('draft'); }); + + test('settings sidebar - URL, tags and meta data persist across a reload', async ({ page }) => { + const created = await postFactory.create({ + title: `react-sidebar-${Date.now()}`, + status: 'draft', + featured: false, + }); + const stamp = Date.now(); + const slug = `react-sidebar-slug-${stamp}`; + const tag = `react-sidebar-tag-${stamp}`; + const metaTitle = `React sidebar meta title ${stamp}`; + const { settings } = editor; + + await editor.gotoPost(created.id); + await settings.open(); + + await settings.url.setSlug(slug); + await settings.tags.add(tag); + + await settings.openSection('meta-data'); + await settings.metaData.titleInput.fill(metaTitle); + await settings.closeSection('meta-data'); + await settings.close(); + + // A draft saves each committed field, so the server catches up without a Cmd-S + await expect + .poll(() => readPostSettings(page, created.id), { timeout: 15000 }) + .toEqual({ slug, tags: [tag], metaTitle }); + + await page.reload(); + await settings.open(); + await expect(settings.url.slugInput).toHaveValue(slug); + await expect(settings.tags.token(tag)).toBeVisible(); + await settings.openSection('meta-data'); + await expect(settings.metaData.titleInput).toHaveValue(metaTitle); + }); }); diff --git a/ghost/core/core/server/lib/request-external.js b/ghost/core/core/server/lib/request-external.js index e0202a7e87b..f15f0c981eb 100644 --- a/ghost/core/core/server/lib/request-external.js +++ b/ghost/core/core/server/lib/request-external.js @@ -13,6 +13,7 @@ const errors = require('@tryghost/errors'); const config = require('../../shared/config'); const validator = require('@tryghost/validator'); const ipaddr = require('ipaddr.js'); +const _ = require('lodash'); // Shared keep-alive agents so outbound HTTPS connections are pooled and reused // across page renders / oEmbed / webmention / recommendations / image probes. @@ -227,6 +228,60 @@ function installSafeDnsLookup(options) { }; } +// fetch requires these statuses to be constructed with a null body +const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]); + +/** + * Wraps a Got instance in a fetch-compatible function so libraries that take a + * custom fetcher still go through the instance's hooks, agents, and timeouts + * + * @param {Got} client + * @returns {(input: string | URL, init?: RequestInit) => Promise} + */ +function createFetch(client) { + return async function fetch(input, init = {}) { + let body; + if (typeof init.body === 'string') { + body = init.body; + } else if (init.body instanceof Uint8Array) { + body = Buffer.from(init.body); + } else if (init.body !== undefined && init.body !== null) { + throw new errors.IncorrectUsageError({ + message: 'externalRequest.fetch only supports string or Uint8Array bodies', + }); + } + + const redirect = init.redirect ?? 'follow'; + + const response = await client(input, { + method: /** @type {import('got').Method} */ (init.method ?? 'GET'), + headers: Object.fromEntries(new Headers(init.headers)), + body, + signal: init.signal ?? undefined, + followRedirect: redirect === 'follow', + throwHttpErrors: false, + responseType: 'buffer', + }); + + if (redirect === 'error' && response.statusCode >= 300 && response.statusCode < 400) { + throw new errors.InternalServerError({ + message: 'Unexpected redirect', + context: response.url, + }); + } + + const res = new Response(NULL_BODY_STATUSES.has(response.statusCode) ? null : response.body, { + status: response.statusCode, + statusText: response.statusMessage, + // raw pairs keep repeated headers (e.g. set-cookie) that response.headers comma-joins + headers: _.chunk(response.rawHeaders, 2), + }); + Object.defineProperty(res, 'url', { value: response.url }); + + return res; + }; +} + // same as our normal request lib but if any request in a redirect chain resolves // to a private IP address it will be blocked before the request is made. // The beforeRequest hooks provide a first-pass DNS check with clear error messages. @@ -254,4 +309,5 @@ const gotOpts = { const externalRequest = got.extend(gotOpts); externalRequest.isPrivateIp = isPrivateIp; externalRequest._installSafeDnsLookup = installSafeDnsLookup; +externalRequest.fetch = createFetch(externalRequest); module.exports = externalRequest; diff --git a/ghost/core/core/server/services/email-service/email-service.js b/ghost/core/core/server/services/email-service/email-service.js index 912c17804ff..631d13b591f 100644 --- a/ghost/core/core/server/services/email-service/email-service.js +++ b/ghost/core/core/server/services/email-service/email-service.js @@ -4,7 +4,7 @@ * @typedef {object} Post * @typedef {object} Email * @typedef {object} LimitService - * @typedef {{checkVerificationRequired(): Promise}} VerificationTrigger + * @typedef {{checkVerificationRequired(options?: {newsletterSend?: boolean}): Promise}} VerificationTrigger * @typedef {import ('./domain-warming-service').DomainWarmingService} DomainWarmingService * * @typedef {object} EmailPreflight - Validation result from a pre-save checkCanSendEmail call @@ -114,7 +114,7 @@ class EmailService { } // Check if email verification is required - if (await this.#verificationTrigger.checkVerificationRequired()) { + if (await this.#verificationTrigger.checkVerificationRequired({ newsletterSend: true })) { const customMessage = this.#config?.get( 'hostSettings:emailVerification:emailSendingDisabledMessage', ); diff --git a/ghost/core/core/server/services/members/service.js b/ghost/core/core/server/services/members/service.js index 1a8c895b7be..f8c99479dd5 100644 --- a/ghost/core/core/server/services/members/service.js +++ b/ghost/core/core/server/services/members/service.js @@ -97,6 +97,31 @@ const buildImporterDeps = ({ stripeAPIService }) => { }; }; +const countRecentEmailRecipients = async (since) => { + const result = await db + .knex('emails') + .sum('email_count', { as: 'count' }) + .where('created_at', '>', since) + .first(); + + return Number(result?.count) || 0; +}; + +// Matched on address, not member id, so changing a member's email counts as removing them. +// The limit lets a clear case stop early rather than scanning every recipient +const countRemovedEmailRecipients = async (since, limit) => { + const rows = await db + .knex('email_recipients as er') + .distinct('er.member_email') + .join('emails as e', 'e.id', 'er.email_id') + .leftJoin('members as m', 'm.email', 'er.member_email') + .where('e.created_at', '>', since) + .whereNull('m.id') + .limit(limit); + + return rows.length; +}; + const initVerificationTrigger = () => { return new VerificationTrigger({ getApiTriggerThreshold: () => @@ -105,6 +130,8 @@ const initVerificationTrigger = () => { _.get(config.get('hostSettings'), 'emailVerification.adminThreshold'), getImportTriggerThreshold: () => _.get(config.get('hostSettings'), 'emailVerification.importThreshold'), + getRemovedRecipientsThreshold: () => + _.get(config.get('hostSettings'), 'emailVerification.removedRecipientsThreshold'), isVerified: () => config.get('hostSettings:emailVerification:verified') === true, isVerificationRequired: () => settingsCache.get('email_verification_required') === true, setVerificationRequired: (value) => settingsCache.set('email_verification_required', { value }), @@ -114,6 +141,8 @@ const initVerificationTrigger = () => { membersStats, Settings: models.Settings, eventRepository: membersApi.events, + countRecentEmailRecipients, + countRemovedEmailRecipients, }); }; diff --git a/ghost/core/core/server/services/oembed/extract-oembed.ts b/ghost/core/core/server/services/oembed/extract-oembed.ts new file mode 100644 index 00000000000..20127311057 --- /dev/null +++ b/ghost/core/core/server/services/oembed/extract-oembed.ts @@ -0,0 +1,33 @@ +import { extract } from '@extractus/oembed-extractor'; +import errors from '@tryghost/errors'; +import { OembedData } from './oembed-schema'; +import { USER_AGENT } from './user-agent'; + +type Fetch = (url: string, init?: RequestInit) => Promise; + +/** + * Fetches oEmbed data for a URL from its allowlisted provider endpoint + * + * `fetch` should be `externalRequest.fetch` so requests get SSRF protection + */ +export async function extractOembed( + url: string, + { fetch, signal }: { fetch: Fetch; signal?: AbortSignal }, +): Promise { + const fetcher = (requestUrl: string) => + fetch(requestUrl, { headers: { 'user-agent': USER_AGENT }, signal }); + + const result = OembedData.safeParse(await extract(url, {}, fetcher)); + if (!result.success) { + throw new errors.ValidationError({ + message: 'Provider returned an invalid oEmbed response', + context: url, + err: result.error, + }); + } + + // extractor tags results with its lookup method; keep card payloads unchanged + delete result.data.method; + + return result.data; +} diff --git a/ghost/core/core/server/services/oembed/oembed-schema.ts b/ghost/core/core/server/services/oembed/oembed-schema.ts new file mode 100644 index 00000000000..c2e00833f6e --- /dev/null +++ b/ghost/core/core/server/services/oembed/oembed-schema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +// providers send dimensions as numbers, numeric strings, "100%", or null +const Dimension = z.union([z.number(), z.string()]).nullish(); + +// Loose on purpose: providers omit spec fields and card consumers read extra ones +export const OembedData = z.looseObject({ + type: z.string().optional(), + version: z.union([z.string(), z.number()]).nullish(), + title: z.string().nullish(), + html: z.string().nullish(), + url: z.string().nullish(), + width: Dimension, + height: Dimension, + author_name: z.string().nullish(), + author_url: z.string().nullish(), + provider_name: z.string().nullish(), + provider_url: z.string().nullish(), + thumbnail_url: z.string().nullish(), + thumbnail_width: Dimension, + thumbnail_height: Dimension, +}); +export type OembedData = z.infer; diff --git a/ghost/core/core/server/services/oembed/oembed-service.js b/ghost/core/core/server/services/oembed/oembed-service.js index eb6195195b7..825d9c5fe69 100644 --- a/ghost/core/core/server/services/oembed/oembed-service.js +++ b/ghost/core/core/server/services/oembed/oembed-service.js @@ -13,7 +13,7 @@ const imageTransform = require('@tryghost/image-transform'); // Some sites block non-standard user agents so we need to mimic a typical browser // Note: the Ghost/5.0 string _may_ be in use by 3rd parties so use caution when updating across majors -const USER_AGENT = 'Mozilla/5.0 (compatible; Ghost/5.0; +https://ghost.org/)'; +const { USER_AGENT } = require('./user-agent'); const DEFAULT_BOOKMARK_ICON = 'https://static.ghost.org/v5.0.0/images/link-icon.svg'; const DEFAULT_REQUEST_TIMEOUT = 5000; @@ -171,10 +171,13 @@ class OEmbedService { * @param {Object} [options] */ async knownProvider(url, options = {}) { - const { extract } = require('@extractus/oembed-extractor'); + const { extractOembed } = require('./extract-oembed'); try { - return await extract(url, {}, options); + return await extractOembed(url, { + fetch: this.externalRequest.fetch, + signal: options.signal, + }); } catch (err) { if ( err.message === 'Request failed with error code 401' || diff --git a/ghost/core/core/server/services/oembed/twitter-oembed-provider.js b/ghost/core/core/server/services/oembed/twitter-oembed-provider.js index bcd49edefc5..5102ea7c7db 100644 --- a/ghost/core/core/server/services/oembed/twitter-oembed-provider.js +++ b/ghost/core/core/server/services/oembed/twitter-oembed-provider.js @@ -45,10 +45,10 @@ class TwitterOEmbedProvider { return null; } - const { extract } = require('@extractus/oembed-extractor'); + const { extractOembed } = require('./extract-oembed'); /** @type {object} */ - const oembedData = await extract(url.href); + const oembedData = await extractOembed(url.href, { fetch: externalRequest.fetch }); if (this.dependencies.config.bearerToken) { const query = { diff --git a/ghost/core/core/server/services/oembed/user-agent.ts b/ghost/core/core/server/services/oembed/user-agent.ts new file mode 100644 index 00000000000..63c2c62a486 --- /dev/null +++ b/ghost/core/core/server/services/oembed/user-agent.ts @@ -0,0 +1,2 @@ +// identifies Ghost so sites and oEmbed providers can allowlist us past bot protection +export const USER_AGENT = 'Mozilla/5.0 (compatible; Ghost/5.0; +https://ghost.org/)'; diff --git a/ghost/core/core/server/services/posts/posts-service.js b/ghost/core/core/server/services/posts/posts-service.js index dc9365a3e7b..9ee1031948b 100644 --- a/ghost/core/core/server/services/posts/posts-service.js +++ b/ghost/core/core/server/services/posts/posts-service.js @@ -368,15 +368,13 @@ class PostsService { }); } - const postRows = await this.#getFilteredBulkPostQuery(options) - .leftJoin('emails', 'posts.id', 'emails.post_id') - .select('posts.id', 'posts.status', 'emails.id as email_id'); + const postRows = await this.#getFilteredBulkPostQuery(options).select( + 'posts.id', + 'posts.status', + ); const deleteIds = postRows.map((row) => row.id); const allDraft = postRows.length > 0 && postRows.every((row) => row.status === 'draft'); - // We also need to collect the email ids because the email relation doesn't have cascase, and we need to delete the related relations of the post - const deleteEmailIds = postRows.map((row) => row.email_id).filter((id) => !!id); - const postTablesToDelete = [ 'posts_authors', 'posts_tags', @@ -385,16 +383,6 @@ class PostsService { 'post_revisions', 'posts_products', ]; - const emailTablesToDelete = [ - 'email_recipient_failures', - 'email_recipients', - 'email_batches', - 'email_spam_complaint_events', - ]; - - // Don't clear, but set relation to null - const emailTablesToSetNull = ['suppressions']; - for (const table of postTablesToDelete) { await this.models.Post.bulkDestroy(deleteIds, table, { column: 'post_id', @@ -403,23 +391,6 @@ class PostsService { }); } - for (const table of emailTablesToDelete) { - await this.models.Post.bulkDestroy(deleteEmailIds, table, { - column: 'email_id', - transacting: options.transacting, - throwErrors: true, - }); - } - - for (const table of emailTablesToSetNull) { - await this.models.Post.bulkEdit(deleteEmailIds, table, { - data: { email_id: null }, - column: 'email_id', - transacting: options.transacting, - throwErrors: true, - }); - } - // The `comments.in_reply_to_id` references form chains between a post's // comments, which MySQL cannot resolve while cascade-deleting them // alongside `comments.parent_id`. Clear the references first so the @@ -431,11 +402,8 @@ class PostsService { throwErrors: true, }); - // Posts and emails - await this.models.Post.bulkDestroy(deleteEmailIds, 'emails', { - transacting: options.transacting, - throwErrors: true, - }); + // A sent post's email and recipients are kept, as when a single post is deleted, + // because host email limits count sends from them const result = await this.models.Post.bulkDestroy(deleteIds, 'posts', { ...options, throwErrors: true, diff --git a/ghost/core/core/server/services/verification-trigger.js b/ghost/core/core/server/services/verification-trigger.js index bc1d86bd2f5..3cdb53246c2 100644 --- a/ghost/core/core/server/services/verification-trigger.js +++ b/ghost/core/core/server/services/verification-trigger.js @@ -13,33 +13,42 @@ class VerificationTrigger { * @param {() => number} deps.getApiTriggerThreshold Threshold for triggering API&Import sourced verifications * @param {() => number} deps.getAdminTriggerThreshold Threshold for triggering Admin sourced verifications * @param {() => number} deps.getImportTriggerThreshold Threshold for triggering Import sourced verifications + * @param {() => number} [deps.getRemovedRecipientsThreshold] Threshold for triggering on recent newsletter recipients who are no longer members * @param {() => boolean} deps.isVerified Check Ghost config to see if we are already verified * @param {() => boolean} deps.isVerificationRequired Check Ghost settings to see whether verification has been requested * @param {(value: boolean) => void} deps.setVerificationRequired Directly update the settings cache for email_verification_required * @param {(content: {amountTriggered: number, threshold: number, method: string}) => Promise} deps.sendVerificationWebhook Sends a webhook to the escalation service to confirm that customer needs to be verified * @param {any} deps.Settings Ghost Settings model * @param {any} deps.eventRepository For querying events + * @param {(since: string) => Promise} [deps.countRecentEmailRecipients] Sums newsletter recipients since the given date + * @param {(since: string, limit: number) => Promise} [deps.countRemovedEmailRecipients] Counts, up to the limit, recipients since the given date whose address no longer belongs to a member */ constructor({ getApiTriggerThreshold, getAdminTriggerThreshold, getImportTriggerThreshold, + getRemovedRecipientsThreshold, isVerified, isVerificationRequired, setVerificationRequired, sendVerificationWebhook, Settings, eventRepository, + countRecentEmailRecipients, + countRemovedEmailRecipients, }) { this._getApiTriggerThreshold = getApiTriggerThreshold; this._getAdminTriggerThreshold = getAdminTriggerThreshold; this._getImportTriggerThreshold = getImportTriggerThreshold; + this._getRemovedRecipientsThreshold = getRemovedRecipientsThreshold || (() => Infinity); this._isVerified = isVerified; this._isVerificationRequired = isVerificationRequired; this._setVerificationRequired = setVerificationRequired || (() => {}); this._sendVerificationWebhook = sendVerificationWebhook || (async () => false); this._Settings = Settings; this._eventRepository = eventRepository; + this._countRecentEmailRecipients = countRecentEmailRecipients; + this._countRemovedEmailRecipients = countRemovedEmailRecipients; this._handleMemberCreatedEvent = this._handleMemberCreatedEvent.bind(this); @@ -60,6 +69,10 @@ class VerificationTrigger { return this._getImportTriggerThreshold(); } + get _removedRecipientsThreshold() { + return this._getRemovedRecipientsThreshold(); + } + /** * * @param {InstanceType} event @@ -167,13 +180,54 @@ class VerificationTrigger { /** * Returns false if email verification is required to send an email. It also updates the verification check and might activate email verification. * Use this when sending emails. + * + * @param {object} [options] + * @param {boolean} [options.newsletterSend] Also run the checks that are only worth their cost before a newsletter send */ - async checkVerificationRequired() { + async checkVerificationRequired({ newsletterSend = false } = {}) { // Check if import threshold is reached (could happen that a long import is in progress and we didn't check the threshold yet) await this.testImportThreshold(); + if (newsletterSend) { + await this.testRemovedRecipientsThreshold(); + } return this._isVerificationRequired() && !this._isVerified(); } + /** + * Catches newsletters sent to members who are then deleted or have their address changed, which + * lets a site email far more people than it keeps as members without adding to any other count + */ + async testRemovedRecipientsThreshold() { + const threshold = this._removedRecipientsThreshold; + if (!Number.isFinite(threshold)) { + return; + } + + if (this._isVerified() || this._isVerificationRequired()) { + return; + } + + const createdAt = new Date(); + createdAt.setDate(createdAt.getDate() - 30); + const since = createdAt.toISOString().replace('T', ' ').substring(0, 19); + + // Cheap bound first: fewer recipients in total than the threshold can't have enough removed ones + if ((await this._countRecentEmailRecipients(since)) <= threshold) { + return; + } + + const removedRecipients = await this._countRemovedEmailRecipients(since, threshold + 1); + if (removedRecipients > threshold) { + await this._startVerificationProcess({ + amount: removedRecipients, + threshold, + method: 'removed_recipients', + throwOnTrigger: false, + source: 'removed_recipients', + }); + } + } + async testImportThreshold() { if (!Number.isFinite(this._importTriggerThreshold)) { // Infinite threshold, quick path @@ -237,7 +291,7 @@ class VerificationTrigger { * @param {object} config * @param {number} config.amount The amount of members that triggered the verification process * @param {number} [config.threshold] The threshold that was exceeded - * @param {string} [config.method] The source that triggered verification - 'api', 'admin', or 'import' + * @param {string} [config.method] The source that triggered verification - 'api', 'admin', 'import', or 'removed_recipients' * @param {boolean} config.throwOnTrigger Whether to throw if verification is needed * @param {string} [config.source] Source of the verification trigger * @returns {Promise} Object containing property "needsVerification" - true when triggered diff --git a/ghost/core/core/server/services/verification/verification-webhook-service.ts b/ghost/core/core/server/services/verification/verification-webhook-service.ts index b31914ad099..bdcce8dd83b 100644 --- a/ghost/core/core/server/services/verification/verification-webhook-service.ts +++ b/ghost/core/core/server/services/verification/verification-webhook-service.ts @@ -3,7 +3,7 @@ const logging = require('@tryghost/logging'); const request = require('@tryghost/request'); const config = require('../../../shared/config'); -type VerificationTriggerMethod = 'admin' | 'api' | 'import'; +type VerificationTriggerMethod = 'admin' | 'api' | 'import' | 'removed_recipients'; type VerificationWebhookBody = { type: string; diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json index d65149bd38f..01440be1f01 100644 --- a/ghost/core/core/shared/config/defaults.json +++ b/ghost/core/core/shared/config/defaults.json @@ -27,7 +27,8 @@ "privacy": false, "security": { "allowWebhookInternalIPs": false, - "staffDeviceVerification": true + "staffDeviceVerification": true, + "embedPreviewUrl": "https://public.ghostembeds.com/" }, "useMinFiles": true, "verifyRequestIntegrity": true, diff --git a/ghost/core/package.json b/ghost/core/package.json index 43d1c35bc1e..ead4e0d90ad 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -79,7 +79,7 @@ "dependencies": { "@aws-sdk/client-s3": "3.1079.0", "@breejs/later": "catalog:", - "@extractus/oembed-extractor": "3.2.1", + "@extractus/oembed-extractor": "catalog:", "@faker-js/faker": "catalog:", "@isaacs/ttlcache": "1.4.1", "@sentry/node": "7.120.4", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap index dea3ce5e03a..984236c360b 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap @@ -17,6 +17,7 @@ Object { "mailgunIsConfigured": false, "security": Object { "allowWebhookInternalIPs": true, + "embedPreviewUrl": "https://public.ghostembeds.com/", "staffDeviceVerification": false, }, "signupForm": Object { diff --git a/ghost/core/test/e2e-api/admin/members.test.js b/ghost/core/test/e2e-api/admin/members.test.js index ead6b6cbe20..61fb5438318 100644 --- a/ghost/core/test/e2e-api/admin/members.test.js +++ b/ghost/core/test/e2e-api/admin/members.test.js @@ -4291,6 +4291,99 @@ describe('Members API', function () { await agent.delete(`/members/${triggerVerificationMember.id}`); await agent.delete(`/members/${recoveryMember.id}`); }); + + it('Blocks newsletters once enough recent recipients are no longer members', async function () { + const { receivedWebhookRequests } = await setupEmailVerificationUtils({ + adminThreshold: 1000, + removedRecipientsThreshold: 2, + }); + + const recipients = []; + for (const n of [1, 2, 3, 4]) { + const { body } = await agent + .post('/members/') + .body({ members: [{ email: `removed-recipient-${n}@example.com` }] }) + .expectStatus(201); + recipients.push(body.members[0]); + } + + const { body: sentPostBody } = await agent + .post('/posts/') + .body({ posts: [{ title: 'Already sent', status: 'draft' }] }) + .expectStatus(201); + const email = await models.Email.add( + { + post_id: sentPostBody.posts[0].id, + uuid: crypto.randomUUID(), + status: 'submitted', + email_count: recipients.length, + recipient_filter: 'all', + subject: 'Already sent', + html: '

    Already sent

    ', + plaintext: 'Already sent', + track_opens: false, + submitted_at: new Date(), + newsletter_id: newsletters[0].id, + }, + { context: { internal: true } }, + ); + const batch = await models.EmailBatch.add( + { email_id: email.id, status: 'submitted' }, + { context: { internal: true } }, + ); + for (const member of recipients) { + await models.EmailRecipient.add( + { + email_id: email.id, + batch_id: batch.id, + member_id: member.id, + member_uuid: member.uuid, + member_email: member.email, + processed_at: new Date(), + }, + { context: { internal: true } }, + ); + } + + await agent.delete(`/members/${recipients[0].id}`).expectStatus(204); + await agent.delete(`/members/${recipients[1].id}`).expectStatus(204); + + assert.equal( + await membersService.verificationTrigger.checkVerificationRequired({ + newsletterSend: true, + }), + false, + 'Two removed recipients should be within the threshold', + ); + + // Changing a member's address removes the old one just as deleting the member does + await agent + .put(`/members/${recipients[2].id}/`) + .body({ members: [{ email: 'removed-recipient-3-changed@example.com' }] }) + .expectStatus(200); + + const { body: nextPostBody } = await agent + .post('/posts/') + .body({ posts: [{ title: 'Next send', status: 'draft' }] }) + .expectStatus(201); + const nextPost = nextPostBody.posts[0]; + + const { body } = await agent + .put(`/posts/${nextPost.id}/?newsletter=${newsletters[0].get('slug')}`) + .body({ posts: [{ ...nextPost, status: 'published' }] }) + .expectStatus(403); + + assert.equal(body.errors[0].code, 'EMAIL_VERIFICATION_NEEDED'); + assert.equal(settingsCache.get('email_verification_required'), true); + assert.deepEqual( + receivedWebhookRequests.map(({ body: webhookBody }) => ({ + method: webhookBody.method, + amountTriggered: webhookBody.amountTriggered, + threshold: webhookBody.threshold, + })), + [{ method: 'removed_recipients', amountTriggered: 3, threshold: 2 }], + ); + }); }); }); diff --git a/ghost/core/test/e2e-api/admin/posts-delete-email-data.test.ts b/ghost/core/test/e2e-api/admin/posts-delete-email-data.test.ts new file mode 100644 index 00000000000..4d797599f05 --- /dev/null +++ b/ghost/core/test/e2e-api/admin/posts-delete-email-data.test.ts @@ -0,0 +1,100 @@ +import ObjectId from 'bson-objectid'; +import assert from 'node:assert/strict'; +import { agentProvider, fixtureManager } from '../../utils/e2e-framework'; + +// @ts-expect-error Module has no type declarations. +import models from '../../../core/server/models'; + +type Agent = Awaited>; + +const EMAIL_TABLES = [ + 'email_batches', + 'email_recipients', + 'email_recipient_failures', + 'email_spam_complaint_events', +] as const; + +// The emails host limit counts sends from these rows, so deleting a post can't reset it +describe('Deleting a sent post', function () { + let agent: Agent; + let email: { id: string; post_id: string }; + + const emailData = async (emailId: string) => { + const knex = models.Base.knex; + const row = await knex('emails').where('id', emailId).first(); + const counts: Record = {}; + for (const table of EMAIL_TABLES) { + const { count } = await knex(table) + .where('email_id', emailId) + .count('id', { as: 'count' }) + .first(); + counts[table] = Number(count); + } + const suppression = await knex('suppressions').where('email_id', emailId).first(); + + return { emailCount: row?.email_count, counts, suppressed: Boolean(suppression) }; + }; + + // Fresh fixtures per test: there's a single fixture email with recipients + beforeEach(async function () { + agent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init( + 'posts', + 'newsletters', + 'members:newsletters', + 'members:emails:failed', + ); + await agent.loginAsOwner(); + + const knex = models.Base.knex; + email = await knex('emails as e') + .join('email_recipients as er', 'er.email_id', 'e.id') + .join('email_recipient_failures as erf', 'erf.email_id', 'e.id') + .select('e.id', 'e.post_id') + .first(); + assert.ok(email, 'Expected a fixture email with recipients and failures'); + + // Fixtures have no spam complaints or suppressions, so add one of each for this email + const recipient = await knex('email_recipients').where('email_id', email.id).first(); + const now = new Date(); + await knex('email_spam_complaint_events').insert({ + id: ObjectId().toHexString(), + email_id: email.id, + member_id: recipient.member_id, + email_address: recipient.member_email, + created_at: now, + }); + await knex('suppressions').insert({ + id: ObjectId().toHexString(), + email: recipient.member_email, + email_id: email.id, + reason: 'spam', + created_at: now, + }); + }); + + const assertEmailDataKept = async (deletePost: () => Promise) => { + const before = await emailData(email.id); + for (const table of EMAIL_TABLES) { + assert.ok(before.counts[table] > 0, `Expected fixture rows in ${table}`); + } + assert.ok(before.suppressed); + + await deletePost(); + + assert.equal(await models.Base.knex('posts').where('id', email.post_id).first(), undefined); + assert.deepEqual(await emailData(email.id), before); + }; + + it('keeps the email data when the post is deleted on its own', async function () { + await assertEmailDataKept(() => agent.delete(`/posts/${email.post_id}/`).expectStatus(204)); + }); + + it('keeps the email data when the post is bulk deleted', async function () { + await assertEmailDataKept(() => + agent + .delete(`/posts/?filter=${encodeURIComponent(`id:'${email.post_id}'`)}`) + .expectStatus(200), + ); + }); +}); diff --git a/ghost/core/test/unit/server/lib/request-external.test.js b/ghost/core/test/unit/server/lib/request-external.test.js index 232bd6e21eb..d11b19efe62 100644 --- a/ghost/core/test/unit/server/lib/request-external.test.js +++ b/ghost/core/test/unit/server/lib/request-external.test.js @@ -856,4 +856,90 @@ describe('External Request', function () { }); }); }); + + describe('fetch', function () { + afterEach(async function () { + await configUtils.restore(); + sinon.restore(); + nock.cleanAll(); + }); + + it('returns a Response instead of throwing on HTTP errors', async function () { + nock('http://some-website.com') + .get('/endpoint/') + .matchHeader('user-agent', 'Test/1.0') + .reply(404, '{"error":"missing"}', { 'content-type': 'application/json' }); + + const res = await externalRequest.fetch('http://some-website.com/endpoint/', { + headers: { 'user-agent': 'Test/1.0' }, + }); + + assert.ok(res instanceof Response); + assert.equal(res.status, 404); + assert.equal(res.ok, false); + assert.equal(res.url, 'http://some-website.com/endpoint/'); + assert.equal(res.headers.get('content-type'), 'application/json'); + assert.deepEqual(await res.json(), { error: 'missing' }); + }); + + it('keeps repeated response headers separate', async function () { + nock('http://some-website.com') + .get('/endpoint/') + .reply(200, 'Response', { + 'set-cookie': ['a=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT', 'b=2'], + }); + + const res = await externalRequest.fetch('http://some-website.com/endpoint/'); + + assert.deepEqual(res.headers.getSetCookie(), [ + 'a=1; Expires=Wed, 21 Oct 2026 07:28:00 GMT', + 'b=2', + ]); + }); + + it('blocks requests that resolve to a private IP', async function () { + dnsPromises.lookup.restore?.(); + sinon.stub(dnsPromises, 'lookup').resolves({ address: '192.168.0.1', family: 4 }); + + const requestMock = nock('http://some-website.com').get('/endpoint/').reply(200, 'Response'); + + await assert.rejects(externalRequest.fetch('http://some-website.com/endpoint/'), { + message: 'URL resolves to a non-permitted private IP block', + }); + assert.equal(requestMock.isDone(), false); + }); + + it('blocks redirects that resolve to a private IP', async function () { + dnsPromises.lookup.restore?.(); + sinon.stub(dnsPromises, 'lookup').callsFake(async (hostname) => { + return { + address: hostname === 'someredirectedurl.com' ? '192.168.0.1' : '123.123.123.123', + family: 4, + }; + }); + + const requestMock = nock('http://some-website.com') + .get('/endpoint/') + .reply(301, '', { location: 'http://someredirectedurl.com/files/' }); + const redirectMock = nock('http://someredirectedurl.com') + .get('/files/') + .reply(200, 'Response'); + + await assert.rejects(externalRequest.fetch('http://some-website.com/endpoint/'), { + message: 'URL resolves to a non-permitted private IP block', + }); + assert.equal(requestMock.isDone(), true); + assert.equal(redirectMock.isDone(), false); + }); + + it('aborts when the signal fires', async function () { + nock('http://some-website.com').get('/endpoint/').delay(1000).reply(200, 'Response'); + + await assert.rejects( + externalRequest.fetch('http://some-website.com/endpoint/', { + signal: AbortSignal.timeout(50), + }), + ); + }); + }); }); diff --git a/ghost/core/test/unit/server/services/oembed/oembed-service.test.js b/ghost/core/test/unit/server/services/oembed/oembed-service.test.js index 6b8996bc4e5..6a24ec26f09 100644 --- a/ghost/core/test/unit/server/services/oembed/oembed-service.test.js +++ b/ghost/core/test/unit/server/services/oembed/oembed-service.test.js @@ -8,6 +8,7 @@ const zlib = require('zlib'); const OembedService = require('../../../../../core/server/services/oembed/oembed-service'); const ghostConfig = require('../../../../../core/shared/config'); +const requestExternal = require('../../../../../core/server/lib/request-external'); describe('oembed-service', function () { /** @type {OembedService} */ @@ -20,7 +21,7 @@ describe('oembed-service', function () { return true; }, }, - externalRequest: got, + externalRequest: requestExternal, }); nock.disableNetConnect(); @@ -93,6 +94,19 @@ describe('oembed-service', function () { assert.equal(error.context, 'Request failed with error code 500'); } }); + + it('should return a ValidationError if upstream returns malformed data', async function () { + nock('https://www.youtube.com') + .get('/oembed') + .query(true) + .reply(200, { type: 'rich', html: { not: 'a string' } }); + + await assert.rejects(oembedService.knownProvider('https://www.youtube.com/watch?v=1234'), { + name: 'ValidationError', + statusCode: 422, + context: 'Provider returned an invalid oEmbed response', + }); + }); }); describe('fetchPage', function () { @@ -556,7 +570,7 @@ describe('oembed-service', function () { .query((query) => { // Ensure the URL is converted to a watch URL and retains existing query params. const actual = query.url; - const expected = 'https://youtube.com/watch?param=existing&v=1234'; + const expected = 'https://www.youtube.com/watch?param=existing&v=1234'; assert.equal(actual, expected, 'URL passed to oembed endpoint is incorrect'); @@ -584,7 +598,7 @@ describe('oembed-service', function () { .query((query) => { // Ensure the URL is converted to a watch URL and retains existing query params. const actual = query.url; - const expected = 'https://youtube.com/watch?param=existing&v=1234'; + const expected = 'https://www.youtube.com/watch?param=existing&v=1234'; assert.equal(actual, expected, 'URL passed to oembed endpoint is incorrect'); diff --git a/ghost/core/test/unit/server/services/verification-trigger.test.js b/ghost/core/test/unit/server/services/verification-trigger.test.js index 0eba2ed15c3..423bb269f96 100644 --- a/ghost/core/test/unit/server/services/verification-trigger.test.js +++ b/ghost/core/test/unit/server/services/verification-trigger.test.js @@ -49,12 +49,15 @@ const assertRecentSourceQuery = (call, source) => { * @property {() => number} [getApiTriggerThreshold] * @property {() => number} [getAdminTriggerThreshold] * @property {() => number} [getImportTriggerThreshold] + * @property {() => number} [getRemovedRecipientsThreshold] * @property {boolean | (() => boolean)} [isVerified] * @property {boolean | (() => boolean)} [isVerificationRequired] * @property {import('sinon').SinonStub} [webhookStub] * @property {import('sinon').SinonStub} [settingsStub] * @property {import('sinon').SinonStub} [setVerificationRequired] * @property {import('sinon').SinonStub} [eventStub] + * @property {import('sinon').SinonStub} [countRecentEmailRecipients] + * @property {import('sinon').SinonStub} [countRemovedEmailRecipients] */ /** @@ -74,17 +77,21 @@ const createVerificationTrigger = ({ getApiTriggerThreshold, getAdminTriggerThreshold, getImportTriggerThreshold, + getRemovedRecipientsThreshold, isVerified = false, isVerificationRequired = false, webhookStub = sinon.stub().resolves(true), settingsStub = sinon.stub().resolves(null), setVerificationRequired = sinon.stub(), eventStub = sinon.stub(), + countRecentEmailRecipients = sinon.stub().resolves(0), + countRemovedEmailRecipients = sinon.stub().resolves(0), } = {}) => { const trigger = new VerificationTrigger({ getApiTriggerThreshold, getAdminTriggerThreshold, getImportTriggerThreshold, + getRemovedRecipientsThreshold, isVerified: typeof isVerified === 'function' ? isVerified : () => isVerified, isVerificationRequired: typeof isVerificationRequired === 'function' @@ -98,6 +105,8 @@ const createVerificationTrigger = ({ eventRepository: { getSignupEvents: eventStub, }, + countRecentEmailRecipients, + countRemovedEmailRecipients, }); return { @@ -562,3 +571,101 @@ describe('Email verification flow', function () { sinon.assert.notCalled(webhookStub); }); }); + +describe('Removed recipients threshold', function () { + beforeEach(function () { + sinon.stub(DomainEvents, 'subscribe'); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('Triggers when enough recent recipients are no longer members', async function () { + const countRemovedEmailRecipients = sinon.stub().resolves(11); + const { trigger, webhookStub } = createVerificationTrigger({ + getRemovedRecipientsThreshold: () => 10, + countRecentEmailRecipients: sinon.stub().resolves(500), + countRemovedEmailRecipients, + }); + + await trigger.testRemovedRecipientsThreshold(); + + assert.match( + countRemovedEmailRecipients.firstCall.args[0], + /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/, + ); + assert.equal(countRemovedEmailRecipients.firstCall.args[1], 11); + sinon.assert.calledOnce(webhookStub); + assert.deepEqual(webhookStub.lastCall.firstArg, { + amountTriggered: 11, + threshold: 10, + method: 'removed_recipients', + }); + }); + + it('Does not trigger at the threshold', async function () { + const { trigger, webhookStub } = createVerificationTrigger({ + getRemovedRecipientsThreshold: () => 10, + countRecentEmailRecipients: sinon.stub().resolves(500), + countRemovedEmailRecipients: sinon.stub().resolves(10), + }); + + await trigger.testRemovedRecipientsThreshold(); + + sinon.assert.notCalled(webhookStub); + }); + + it('Skips the removed recipients query when total recipients are within the threshold', async function () { + const countRemovedEmailRecipients = sinon.stub().resolves(0); + const { trigger } = createVerificationTrigger({ + getRemovedRecipientsThreshold: () => 10, + countRecentEmailRecipients: sinon.stub().resolves(10), + countRemovedEmailRecipients, + }); + + await trigger.testRemovedRecipientsThreshold(); + + sinon.assert.notCalled(countRemovedEmailRecipients); + }); + + it('Does not query anything when the threshold is not configured', async function () { + const countRecentEmailRecipients = sinon.stub().resolves(0); + const { trigger } = createVerificationTrigger({ countRecentEmailRecipients }); + + await trigger.testRemovedRecipientsThreshold(); + + sinon.assert.notCalled(countRecentEmailRecipients); + }); + + it('Does not query anything when the site is verified or already in review', async function () { + const countRecentEmailRecipients = sinon.stub().resolves(0); + + for (const state of [{ isVerified: true }, { isVerificationRequired: true }]) { + const { trigger } = createVerificationTrigger({ + ...state, + getRemovedRecipientsThreshold: () => 10, + countRecentEmailRecipients, + }); + + await trigger.testRemovedRecipientsThreshold(); + } + + sinon.assert.notCalled(countRecentEmailRecipients); + }); + + it('Only runs from checkVerificationRequired before a newsletter send', async function () { + const countRecentEmailRecipients = sinon.stub().resolves(0); + const { trigger } = createVerificationTrigger({ + getImportTriggerThreshold: () => Infinity, + getRemovedRecipientsThreshold: () => 10, + countRecentEmailRecipients, + }); + + await trigger.checkVerificationRequired(); + sinon.assert.notCalled(countRecentEmailRecipients); + + await trigger.checkVerificationRequired({ newsletterSend: true }); + sinon.assert.calledOnce(countRecentEmailRecipients); + }); +}); diff --git a/ghost/core/test/utils/email-verification-utils.ts b/ghost/core/test/utils/email-verification-utils.ts index 996e4c48b6c..320a8d69a7a 100644 --- a/ghost/core/test/utils/email-verification-utils.ts +++ b/ghost/core/test/utils/email-verification-utils.ts @@ -13,6 +13,7 @@ type EmailVerificationUtilsOptions = { apiThreshold?: number; adminThreshold?: number; importThreshold?: number; + removedRecipientsThreshold?: number; persist?: boolean; siteId?: string; verified?: boolean; @@ -31,6 +32,7 @@ export async function setupEmailVerificationUtils({ apiThreshold = 0, adminThreshold = 1, importThreshold = 0, + removedRecipientsThreshold, verified = false, persist = false, siteId = '1', @@ -55,6 +57,7 @@ export async function setupEmailVerificationUtils({ apiThreshold, adminThreshold, importThreshold, + removedRecipientsThreshold, verified, webhookType: DEFAULT_WEBHOOK_TYPE, webhookUrl, diff --git a/package.json b/package.json index e3ad07527ef..1a54b64f1d6 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,10 @@ "rollback:db": "docker exec --workdir /home/ghost/ghost/core ghost-dev pnpm knex-migrator rollback", "reset:db": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && pnpm knex-migrator reset && pnpm knex-migrator init'", "reset:db:volume": "docker compose -f compose.dev.yaml ${DEV_COMPOSE_FILES} down && docker volume rm ghost-dev_mysql-data", - "reset:data": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node index.js generate-data --clear-database --quantities members:1000,posts:100 --seed 123'", - "reset:data:empty": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node index.js generate-data --clear-database --quantities members:0,posts:0 --seed 123'", - "reset:data:xxl": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node index.js generate-data --clear-database --quantities members:2000000,posts:0,emails:0,members_stripe_customers:0,members_login_events:0,members_status_events:0 --seed 123'", - "seed:multi-sub-scenarios": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node scripts/seed-multi-sub-scenarios.js'", + "reset:data": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node --conditions=source --import=tsx index.js generate-data --clear-database --quantities members:1000,posts:100 --seed 123'", + "reset:data:empty": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node --conditions=source --import=tsx index.js generate-data --clear-database --quantities members:0,posts:0 --seed 123'", + "reset:data:xxl": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node --conditions=source --import=tsx index.js generate-data --clear-database --quantities members:2000000,posts:0,emails:0,members_stripe_customers:0,members_login_events:0,members_status_events:0 --seed 123'", + "seed:multi-sub-scenarios": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node --conditions=source --import=tsx scripts/seed-multi-sub-scenarios.js'", "docker:build": "docker compose -f compose.dev.yaml ${DEV_COMPOSE_FILES} build", "docker:build:verbose": "docker compose --progress=plain -f compose.dev.yaml ${DEV_COMPOSE_FILES} build", "docker:clean": "docker compose -f compose.dev.yaml ${DEV_COMPOSE_FILES} --profile all down -v --remove-orphans --rmi local", diff --git a/packages/testing/test-data/src/selectors/editor.ts b/packages/testing/test-data/src/selectors/editor.ts index 351e462e34f..7a55a0659da 100644 --- a/packages/testing/test-data/src/selectors/editor.ts +++ b/packages/testing/test-data/src/selectors/editor.ts @@ -156,6 +156,7 @@ export const showTitleLearnMoreLink = 'Learn more'; export const addFeatureImageLabel = 'Add feature image'; export const featureImageAltLabel = 'Alt text for feature image'; export const featureImageUnsplashButton = 'Select feature image from Unsplash'; +export const unsplashSearchHeading = 'Unsplash'; export const removeFeatureImageButton = 'Remove feature image'; export const addFacebookImageLabel = 'Add Facebook image'; export const removeFacebookImageButton = 'Remove Facebook image'; @@ -190,3 +191,16 @@ export const codeInjectionHeadLabel = 'Post header'; export const codeInjectionFootLabel = 'Post footer'; export const codeInjectionPageHeadLabel = 'Page header'; export const codeInjectionPageFootLabel = 'Page footer'; +export const settingsCodeInjectionRow = 'Code injection'; +export const settingsCodeInjectionBackButton = 'Close code injection panel'; +export const settingsMetaDataRow = 'Meta data'; +export const settingsMetaDataBackButton = 'Close meta data panel'; +export const settingsXCardRow = 'X card'; +export const settingsXCardBackButton = 'Close X card panel'; +export const settingsFacebookCardRow = 'Facebook card'; +export const settingsFacebookCardBackButton = 'Close Facebook card panel'; +export const settingsKeyboardShortcutsRow = 'Keyboard shortcuts'; +export const settingsKeyboardShortcutsBackButton = 'Close keyboard shortcuts panel'; + +// text fragments +export const settingsTagsCreateText = 'Create'; diff --git a/packages/testing/test-data/src/selectors/global-search.ts b/packages/testing/test-data/src/selectors/global-search.ts new file mode 100644 index 00000000000..2dfeb2de608 --- /dev/null +++ b/packages/testing/test-data/src/selectors/global-search.ts @@ -0,0 +1,13 @@ +/** + * Cmd-K search selector strings, consumed by the admin screen helpers and + * the e2e page objects. + * Source of truth: apps/admin/src/global-search and the sidebar's search button. + */ + +// accessible names +export const searchSiteButton = 'Search site'; +export const searchDialog = 'Search site'; + +// text fragments +export const noResultsText = 'No results found'; +export const shortcutHintText = 'Open with'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31d992770e5..c45a4e62524 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -221,6 +221,9 @@ catalogs: '@eslint/js': specifier: 9.39.5 version: 9.39.5 + '@extractus/oembed-extractor': + specifier: 6.0.0 + version: 6.0.0 '@faker-js/faker': specifier: 10.5.0 version: 10.5.0 @@ -2452,8 +2455,8 @@ importers: specifier: 'catalog:' version: 4.2.0 '@extractus/oembed-extractor': - specifier: 3.2.1 - version: 3.2.1(encoding@0.1.13) + specifier: 'catalog:' + version: 6.0.0 '@faker-js/faker': specifier: 'catalog:' version: 10.5.0 @@ -6314,9 +6317,8 @@ packages: '@noble/hashes': optional: true - '@extractus/oembed-extractor@3.2.1': - resolution: {integrity: sha512-ZwOY/SFqDph/1J4KfTTOKgGcy5xIrOSkK8b5KDHyoIg8AZ6+uEQ5l7SSNQskc4RxlLZGk+CBNOuRUGfkdPhKLA==} - engines: {node: '>= 15'} + '@extractus/oembed-extractor@6.0.0': + resolution: {integrity: sha512-CQz0BwCZ6cv9KqSVLacOpPT5h0FgkWFO3krddAjv2FOgkDiVSNK2iHjESXzqdu9ttHZ0dmI6KtuY80ivtzXucA==} '@faker-js/faker@10.4.0': resolution: {integrity: sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==} @@ -12828,9 +12830,6 @@ packages: cron-validate@1.4.5: resolution: {integrity: sha512-nKlOJEnYKudMn/aNyNH8xxWczlfpaazfWV32Pcx/2St51r2bxWbGhZD7uwzMcRhunA/ZNL+Htm/i0792Z59UMQ==} - cross-fetch@4.1.0: - resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} - cross-spawn@6.0.6: resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} engines: {node: '>=4.8'} @@ -25252,11 +25251,11 @@ snapshots: optionalDependencies: '@noble/hashes': 1.8.0 - '@extractus/oembed-extractor@3.2.1(encoding@0.1.13)': + '@extractus/oembed-extractor@6.0.0': dependencies: - cross-fetch: 4.1.0(encoding@0.1.13) + linkedom: 0.18.13 transitivePeerDependencies: - - encoding + - canvas '@faker-js/faker@10.4.0': {} @@ -33580,12 +33579,6 @@ snapshots: dependencies: yup: 0.32.9 - cross-fetch@4.1.0(encoding@0.1.13): - dependencies: - node-fetch: 2.7.0(encoding@0.1.13) - transitivePeerDependencies: - - encoding - cross-spawn@6.0.6: dependencies: nice-try: 1.0.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ec2856f9efc..c46003abc7f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -51,6 +51,7 @@ catalog: '@codemirror/view': 6.43.6 '@eslint/compat': 2.1.0 '@eslint/js': 9.39.5 + '@extractus/oembed-extractor': 6.0.0 '@faker-js/faker': 10.5.0 '@playwright/test': 1.61.1 '@radix-ui/react-avatar': 1.2.1