From 927009a542f10fd629eed7a4edb7538bbe98d890 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Fri, 25 Sep 2026 02:58:04 -0400 Subject: [PATCH 01/11] Updated @extractus/oembed-extractor to v6 (#30677) no ref v4 and v5 were ESM-only, which blocked earlier upgrade attempts; v6 ships a CommonJS build again. v6 replaced extract()'s fetch-options argument with a fetcher function, which lets oEmbed provider requests go through request-external's SSRF protection instead of the library's own HTTP client. A new externalRequest.fetch wraps the Got instance in a fetch-compatible API for this. The extractOembed helper passes the abort signal and Ghost's own user agent (the same allowlisting reason as 571eb68), validates provider data with a loose Zod schema, and strips the `method` field v6 adds so card payloads stay unchanged. The version now lives in the pnpm catalog. v6's stricter scheme matching resolves bare youtube.com URLs through the www fallback, so the YouTube live-URL tests now expect the www form. --- .../core/core/server/lib/request-external.js | 56 ++++++++++++ .../server/services/oembed/extract-oembed.ts | 33 +++++++ .../server/services/oembed/oembed-schema.ts | 23 +++++ .../server/services/oembed/oembed-service.js | 9 +- .../oembed/twitter-oembed-provider.js | 4 +- .../core/server/services/oembed/user-agent.ts | 2 + ghost/core/package.json | 2 +- .../unit/server/lib/request-external.test.js | 86 +++++++++++++++++++ .../services/oembed/oembed-service.test.js | 20 ++++- pnpm-lock.yaml | 27 +++--- pnpm-workspace.yaml | 1 + 11 files changed, 237 insertions(+), 26 deletions(-) create mode 100644 ghost/core/core/server/services/oembed/extract-oembed.ts create mode 100644 ghost/core/core/server/services/oembed/oembed-schema.ts create mode 100644 ghost/core/core/server/services/oembed/user-agent.ts 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/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/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/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/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 From 3621a716e5f8a55daaca45a127b8f9b1fedccdb6 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Fri, 25 Sep 2026 09:50:55 +0200 Subject: [PATCH 02/11] Removed unused Ember admin components and helpers (#30947) no ref Cleanup: members, labels, offers, tiers, settings, integrations, themes and the dashboard moved to React. --- .../app/components/aspect-ratio-box.hbs | 3 - .../app/components/aspect-ratio-box.js | 53 --- .../app/components/gh-blog-url.hbs | 1 - .../ember-admin/app/components/gh-blog-url.js | 10 - .../app/components/gh-browser-preview.hbs | 23 -- .../app/components/gh-browser-preview.js | 6 - .../app/components/gh-date-picker.hbs | 51 --- .../app/components/gh-date-picker.js | 146 ------- .../app/components/gh-facebook-url-input.hbs | 10 - .../app/components/gh-facebook-url-input.js | 65 ---- .../app/components/gh-html-iframe.hbs | 8 - .../app/components/gh-html-iframe.js | 81 ---- .../app/components/gh-member-details.hbs | 123 ------ .../app/components/gh-member-details.js | 16 - .../components/gh-members-segment-select.hbs | 16 - .../components/gh-members-segment-select.js | 106 ----- .../app/components/gh-resource-select.hbs | 19 - .../app/components/gh-resource-select.js | 184 --------- .../app/components/gh-theme-error-li.hbs | 31 -- .../app/components/gh-theme-error-li.js | 16 - .../gh-token-input/label-selected-item.hbs | 1 - .../components/gh-token-input/label-token.hbs | 1 - .../components/gh-token-input/label-token.js | 17 - .../app/components/gh-twitter-url-input.hbs | 10 - .../app/components/gh-twitter-url-input.js | 63 --- .../app/components/gh-url-input.hbs | 9 - .../app/components/gh-url-input.js | 40 -- .../app/components/gh-view-title.hbs | 1 - .../app/components/gh-view-title.js | 9 - .../app/components/liquid-container.js | 12 - .../member-filter-trigger.hbs | 1 - .../components/modal-impersonate-member.hbs | 54 --- .../components/modal-impersonate-member.js | 40 -- .../components/modal-members-label-form.hbs | 71 ---- .../components/modal-members-label-form.js | 90 ----- .../modals/new-custom-integration.hbs | 48 --- .../modals/new-custom-integration.js | 64 --- .../app/components/posts-list/list-item.hbs | 274 ------------- .../app/components/posts-list/list-item.js | 73 ---- .../app/helpers/activity-feed-fetcher.js | 155 -------- .../app/helpers/currency-symbol.js | 14 - .../app/helpers/full-email-address.js | 14 - .../app/helpers/gh-count-characters.js | 31 -- .../helpers/gh-count-down-html-characters.js | 21 - .../app/helpers/gh-price-amount.js | 19 - apps/ember-admin/app/helpers/hex-contrast.js | 6 - .../app/helpers/history-event-fetcher.js | 132 ------- .../app/helpers/history-event-filter.js | 41 -- .../app/helpers/humanize-setting-key.js | 17 - .../app/helpers/integration-icon-style.js | 11 - .../app/helpers/most-recently-updated.js | 19 - .../app/helpers/most-relevant-subscription.js | 39 -- apps/ember-admin/app/helpers/set-has.js | 5 - apps/ember-admin/app/helpers/split-number.js | 23 -- .../ember-admin/app/helpers/toggle-feature.js | 13 - apps/ember-admin/app/helpers/ui-btn-span.js | 8 - apps/ember-admin/app/helpers/ui-btn.js | 60 --- apps/ember-admin/app/helpers/ui-text.js | 42 -- .../app/mixins/validation-state.js | 51 --- apps/ember-admin/app/modifiers/movable.js | 237 ------------ .../app/modifiers/scroll-into-view.js | 54 --- apps/ember-admin/app/services/data-cache.js | 37 -- .../ember-admin/app/transforms/moment-date.js | 18 - .../app/transforms/visibility-string.js | 29 -- .../app/utils/merge-stats-by-date.js | 26 -- .../app/utils/password-generator.js | 86 ----- apps/ember-admin/app/validators/subscriber.js | 25 -- .../components/gh-date-picker-test.js | 363 ------------------ .../gh-members-segment-select-test.js | 191 --------- .../helpers/activity-feed-fetcher-test.js | 139 ------- .../unit/helpers/gh-count-characters-test.js | 37 -- .../helpers/most-recently-updated-test.js | 64 --- .../most-relevant-subscription-test.js | 72 ---- .../unit/utils/merge-stats-by-date-test.js | 184 --------- 74 files changed, 4129 deletions(-) delete mode 100644 apps/ember-admin/app/components/aspect-ratio-box.hbs delete mode 100644 apps/ember-admin/app/components/aspect-ratio-box.js delete mode 100644 apps/ember-admin/app/components/gh-blog-url.hbs delete mode 100644 apps/ember-admin/app/components/gh-blog-url.js delete mode 100644 apps/ember-admin/app/components/gh-browser-preview.hbs delete mode 100644 apps/ember-admin/app/components/gh-browser-preview.js delete mode 100644 apps/ember-admin/app/components/gh-date-picker.hbs delete mode 100644 apps/ember-admin/app/components/gh-date-picker.js delete mode 100644 apps/ember-admin/app/components/gh-facebook-url-input.hbs delete mode 100644 apps/ember-admin/app/components/gh-facebook-url-input.js delete mode 100644 apps/ember-admin/app/components/gh-html-iframe.hbs delete mode 100644 apps/ember-admin/app/components/gh-html-iframe.js delete mode 100644 apps/ember-admin/app/components/gh-member-details.hbs delete mode 100644 apps/ember-admin/app/components/gh-member-details.js delete mode 100644 apps/ember-admin/app/components/gh-members-segment-select.hbs delete mode 100644 apps/ember-admin/app/components/gh-members-segment-select.js delete mode 100644 apps/ember-admin/app/components/gh-resource-select.hbs delete mode 100644 apps/ember-admin/app/components/gh-resource-select.js delete mode 100644 apps/ember-admin/app/components/gh-theme-error-li.hbs delete mode 100644 apps/ember-admin/app/components/gh-theme-error-li.js delete mode 100644 apps/ember-admin/app/components/gh-token-input/label-selected-item.hbs delete mode 100644 apps/ember-admin/app/components/gh-token-input/label-token.hbs delete mode 100644 apps/ember-admin/app/components/gh-token-input/label-token.js delete mode 100644 apps/ember-admin/app/components/gh-twitter-url-input.hbs delete mode 100644 apps/ember-admin/app/components/gh-twitter-url-input.js delete mode 100644 apps/ember-admin/app/components/gh-url-input.hbs delete mode 100644 apps/ember-admin/app/components/gh-url-input.js delete mode 100644 apps/ember-admin/app/components/gh-view-title.hbs delete mode 100644 apps/ember-admin/app/components/gh-view-title.js delete mode 100644 apps/ember-admin/app/components/liquid-container.js delete mode 100644 apps/ember-admin/app/components/members-activity/member-filter-trigger.hbs delete mode 100644 apps/ember-admin/app/components/modal-impersonate-member.hbs delete mode 100644 apps/ember-admin/app/components/modal-impersonate-member.js delete mode 100644 apps/ember-admin/app/components/modal-members-label-form.hbs delete mode 100644 apps/ember-admin/app/components/modal-members-label-form.js delete mode 100644 apps/ember-admin/app/components/modals/new-custom-integration.hbs delete mode 100644 apps/ember-admin/app/components/modals/new-custom-integration.js delete mode 100644 apps/ember-admin/app/components/posts-list/list-item.hbs delete mode 100644 apps/ember-admin/app/components/posts-list/list-item.js delete mode 100644 apps/ember-admin/app/helpers/activity-feed-fetcher.js delete mode 100644 apps/ember-admin/app/helpers/currency-symbol.js delete mode 100644 apps/ember-admin/app/helpers/full-email-address.js delete mode 100644 apps/ember-admin/app/helpers/gh-count-characters.js delete mode 100644 apps/ember-admin/app/helpers/gh-count-down-html-characters.js delete mode 100644 apps/ember-admin/app/helpers/gh-price-amount.js delete mode 100644 apps/ember-admin/app/helpers/hex-contrast.js delete mode 100644 apps/ember-admin/app/helpers/history-event-fetcher.js delete mode 100644 apps/ember-admin/app/helpers/history-event-filter.js delete mode 100644 apps/ember-admin/app/helpers/humanize-setting-key.js delete mode 100644 apps/ember-admin/app/helpers/integration-icon-style.js delete mode 100644 apps/ember-admin/app/helpers/most-recently-updated.js delete mode 100644 apps/ember-admin/app/helpers/most-relevant-subscription.js delete mode 100644 apps/ember-admin/app/helpers/set-has.js delete mode 100644 apps/ember-admin/app/helpers/split-number.js delete mode 100644 apps/ember-admin/app/helpers/toggle-feature.js delete mode 100644 apps/ember-admin/app/helpers/ui-btn-span.js delete mode 100644 apps/ember-admin/app/helpers/ui-btn.js delete mode 100644 apps/ember-admin/app/helpers/ui-text.js delete mode 100644 apps/ember-admin/app/mixins/validation-state.js delete mode 100644 apps/ember-admin/app/modifiers/movable.js delete mode 100644 apps/ember-admin/app/modifiers/scroll-into-view.js delete mode 100644 apps/ember-admin/app/services/data-cache.js delete mode 100644 apps/ember-admin/app/transforms/moment-date.js delete mode 100644 apps/ember-admin/app/transforms/visibility-string.js delete mode 100644 apps/ember-admin/app/utils/merge-stats-by-date.js delete mode 100644 apps/ember-admin/app/utils/password-generator.js delete mode 100644 apps/ember-admin/app/validators/subscriber.js delete mode 100644 apps/ember-admin/tests/integration/components/gh-date-picker-test.js delete mode 100644 apps/ember-admin/tests/integration/components/gh-members-segment-select-test.js delete mode 100644 apps/ember-admin/tests/integration/helpers/activity-feed-fetcher-test.js delete mode 100644 apps/ember-admin/tests/unit/helpers/gh-count-characters-test.js delete mode 100644 apps/ember-admin/tests/unit/helpers/most-recently-updated-test.js delete mode 100644 apps/ember-admin/tests/unit/helpers/most-relevant-subscription-test.js delete mode 100644 apps/ember-admin/tests/unit/utils/merge-stats-by-date-test.js 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/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/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/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/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 - } - ]); - }); -}); From 5f9bb5edc76e187e6178b574003abd7cadb7a10b Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Fri, 25 Sep 2026 09:57:30 +0200 Subject: [PATCH 03/11] Added the React editor's settings sidebar to the e2e page object (#30957) no ref The e2e page object for the React post editor could only reach five settings fields. This gives it the whole sidebar, the header and the feature image, so browser specs for the sidebar can be written as a sequence of gestures without touching the page object again. --- ...ettings-code-injection.acceptance.test.tsx | 22 +- ...settings-facebook-card.acceptance.test.tsx | 17 +- ...ngs-keyboard-shortcuts.acceptance.test.tsx | 26 +- ...tor-settings-meta-data.acceptance.test.tsx | 40 +- .../editor-settings-tags.acceptance.test.tsx | 7 +- ...editor-settings-x-card.acceptance.test.tsx | 12 +- apps/admin/src/editor/editor.screen.ts | 3 +- e2e/helpers/pages/admin/posts/post/index.ts | 4 + .../admin/posts/post/post-editor-header.ts | 69 ++ .../admin/posts/post/post-editor-page.ts | 47 +- .../admin/posts/post/post-feature-image.ts | 77 ++ .../admin/posts/post/post-settings-sidebar.ts | 857 ++++++++++++++++++ e2e/tests/admin/posts/editor-react.test.ts | 50 + .../testing/test-data/src/selectors/editor.ts | 14 + 14 files changed, 1170 insertions(+), 75 deletions(-) create mode 100644 e2e/helpers/pages/admin/posts/post/post-editor-header.ts create mode 100644 e2e/helpers/pages/admin/posts/post/post-feature-image.ts create mode 100644 e2e/helpers/pages/admin/posts/post/post-settings-sidebar.ts 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/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..63c6484edc5 --- /dev/null +++ b/e2e/helpers/pages/admin/posts/post/post-settings-sidebar.ts @@ -0,0 +1,857 @@ +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); + } +} + +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/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/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'; From e0ed37d2c72fce3d0fcf31ba82633318bd0b067e Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Fri, 25 Sep 2026 10:00:08 +0200 Subject: [PATCH 04/11] Wired up Admin search in React (#30970) ref https://linear.app/ghost/issue/PLA-408 This is behind the `globalSearchReact` flag. --- .../src/ember-bridge/ember-bridge.test.tsx | 19 ++ apps/admin/src/ember-bridge/ember-bridge.tsx | 14 + apps/admin/src/ember-bridge/index.ts | 1 + .../global-search/global-search-context.ts | 8 + .../src/global-search/global-search-modal.tsx | 162 +++++++++++ .../global-search/global-search-provider.tsx | 71 +++++ .../global-search.acceptance.test.tsx | 262 ++++++++++++++++++ .../src/global-search/global-search.screen.ts | 56 ++++ .../global-search/search-destination.test.ts | 45 +++ .../src/global-search/search-destination.ts | 37 +++ .../src/global-search/search-shortcut.test.ts | 54 ++++ .../src/global-search/search-shortcut.ts | 18 ++ apps/admin/src/layout/admin-layout.tsx | 65 ++--- .../layout/app-sidebar/app-sidebar-header.tsx | 12 +- apps/admin/src/routes.tsx | 42 ++- apps/ember-admin/app/routes/application.js | 6 + apps/ember-admin/app/services/state-bridge.js | 8 + .../tests/acceptance/search-test.js | 23 ++ .../tests/unit/services/state-bridge-test.js | 11 + .../pages/admin/sidebar/sidebar-page.ts | 3 +- .../test-data/src/selectors/global-search.ts | 13 + 21 files changed, 878 insertions(+), 52 deletions(-) create mode 100644 apps/admin/src/global-search/global-search-context.ts create mode 100644 apps/admin/src/global-search/global-search-modal.tsx create mode 100644 apps/admin/src/global-search/global-search-provider.tsx create mode 100644 apps/admin/src/global-search/global-search.acceptance.test.tsx create mode 100644 apps/admin/src/global-search/global-search.screen.ts create mode 100644 apps/admin/src/global-search/search-destination.test.ts create mode 100644 apps/admin/src/global-search/search-destination.ts create mode 100644 apps/admin/src/global-search/search-shortcut.test.ts create mode 100644 apps/admin/src/global-search/search-shortcut.ts create mode 100644 packages/testing/test-data/src/selectors/global-search.ts 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..590b511aece --- /dev/null +++ b/apps/admin/src/global-search/global-search-modal.tsx @@ -0,0 +1,162 @@ +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}; +} + +interface GlobalSearchModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function GlobalSearchModal({ open, onOpenChange }: GlobalSearchModalProps) { + 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); + onOpenChange(false); + + 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 ( + + + Search site + + + + {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 + + )} + + + ); +} 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..fbceaa5911c --- /dev/null +++ b/apps/admin/src/global-search/global-search.acceptance.test.tsx @@ -0,0 +1,262 @@ +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 { 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() { + fakeAdminEndpoint('GET', '/search-index/posts/', { + posts: [{ id: 'p1', title: 'First post', status: 'draft' }], + }); + fakeAdminEndpoint('GET', '/search-index/pages/', { + pages: [{ id: 'g1', title: 'First page', status: 'published' }], + }); + fakeAdminEndpoint('GET', '/search-index/tags/', { + tags: [{ id: 't1', slug: 'first-tag', name: 'First tag' }], + }); + 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, with the billing handoff this feature calls. */ +function installEmberBridge() { + const navigateToBillingSubRoute = vi.fn(); + const state = { + on: () => {}, + off: () => {}, + sidebarVisible: true, + getRouteUrl: (routeName: string) => routeName, + isRouteActive: () => false, + navigateToBillingSubRoute, + }; + window.EmberBridge = { state } as unknown as typeof window.EmberBridge; + return navigateToBillingSubRoute; +} + +/** 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', () => { + beforeEach(() => { + delete document.body.dataset.externalNavigate; + fakeTags([]); + 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('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/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/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/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/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/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/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'; From 5e4ab4d5b08b9bf8e48fcddebd62235483f88f8a Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Fri, 25 Sep 2026 04:28:11 -0400 Subject: [PATCH 05/11] Fixed bulk post deletes removing sent email history (#30981) no ref Deleting a single post (`DELETE /posts/:id/`) keeps its `emails` and `email_recipients` rows. Bulk deleting posts (`DELETE /posts/?filter=...`, which also works for a single post) deleted the email along with its batches, recipients, recipient failures and spam complaint events, and nulled `suppressions.email_id`. The two paths disagreed without a reason: `emails.post_id` has no foreign key, so nothing requires the email to go with the post. The deletion came in with the original bulk destroy API (#16587) as clean-up, and single delete has always left the email in place. It matters because the `emails` host limit counts sends from `emails.email_count`, so bulk deleting sent posts reset a site's usage for the period. Bulk delete now matches single delete and keeps the email data. **Tests** - New `test/e2e-api/admin/posts-delete-email-data.test.ts` covers both paths: the email row and `email_count`, its batches, recipients, recipient failures, spam complaint events and linked suppression all survive deleting a sent post. The bulk case fails on `main`. - `posts-bulk.test.js` (18) and posts unit tests (87) still pass. **Side effect:** bulk-deleted sent posts now leave orphaned email rows, the same as single deletes already do. --- .../server/services/posts/posts-service.js | 44 ++------ .../admin/posts-delete-email-data.test.ts | 100 ++++++++++++++++++ 2 files changed, 106 insertions(+), 38 deletions(-) create mode 100644 ghost/core/test/e2e-api/admin/posts-delete-email-data.test.ts 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/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), + ); + }); +}); From e790f18956840b77ea3a4c8baff7ec2b81b5d89f Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Fri, 25 Sep 2026 10:28:19 +0200 Subject: [PATCH 06/11] Stopped the Cmd-K search index loading while search is closed (#30982) ref https://linear.app/ghost/issue/PLA-408 The Cmd-K modal stays mounted after it closes and kept its last term, so the search index queries stayed active while search was closed. Every later save that invalidated posts, pages, tags or staff then re-downloaded those lists in the background. After opening a post from search, for example, each editor autosave re-fetched up to 10,000 posts plus all tags. --- .../src/global-search/global-search-modal.tsx | 109 ++++++++++-------- .../global-search.acceptance.test.tsx | 75 +++++++++--- 2 files changed, 116 insertions(+), 68 deletions(-) diff --git a/apps/admin/src/global-search/global-search-modal.tsx b/apps/admin/src/global-search/global-search-modal.tsx index 590b511aece..7ea778aaa2e 100644 --- a/apps/admin/src/global-search/global-search-modal.tsx +++ b/apps/admin/src/global-search/global-search-modal.tsx @@ -76,12 +76,8 @@ function StatusBadge({ status }: { status?: string }) { return {badge.label}; } -interface GlobalSearchModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export default function GlobalSearchModal({ open, onOpenChange }: GlobalSearchModalProps) { +/** 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(); @@ -90,7 +86,7 @@ export default function GlobalSearchModal({ open, onOpenChange }: GlobalSearchMo const openResult = (result: SearchResult) => { const destination = getSearchDestination(result); - onOpenChange(false); + onClose(); if (!destination) { return; @@ -109,53 +105,66 @@ export default function GlobalSearchModal({ open, onOpenChange }: GlobalSearchMo 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 - - - - {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 - - )} + onOpenChange(false)} /> ); diff --git a/apps/admin/src/global-search/global-search.acceptance.test.tsx b/apps/admin/src/global-search/global-search.acceptance.test.tsx index fbceaa5911c..ea3af26cb49 100644 --- a/apps/admin/src/global-search/global-search.acceptance.test.tsx +++ b/apps/admin/src/global-search/global-search.acceptance.test.tsx @@ -12,6 +12,7 @@ import { 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'; @@ -23,18 +24,20 @@ const handoff = () => JSON.parse(document.body.dataset.externalNavigate ?? 'null') as { route: string } | null; function fakeSearchIndex() { - fakeAdminEndpoint('GET', '/search-index/posts/', { - posts: [{ id: 'p1', title: 'First post', status: 'draft' }], - }); - fakeAdminEndpoint('GET', '/search-index/pages/', { - pages: [{ id: 'g1', title: 'First page', status: 'published' }], - }); - fakeAdminEndpoint('GET', '/search-index/tags/', { - tags: [{ id: 't1', slug: 'first-tag', name: 'First tag' }], - }); - fakeAdminEndpoint('GET', '/search-index/users/', { - users: [{ id: 'u1', slug: 'first-user', name: 'First user' }], - }); + 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 { @@ -51,19 +54,34 @@ function withBilling(): RenderAdminAppOptions { return { ...flagOn, boot: { browseConfig: { response: config } } }; } -/** The Ember half of the state bridge, with the billing handoff this feature calls. */ +/** 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: () => {}, - off: () => {}, + 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; + + 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. */ @@ -83,10 +101,12 @@ async function closeWithEscape() { } describe('Cmd-K search', () => { + let index: ReturnType; + beforeEach(() => { delete document.body.dataset.externalNavigate; fakeTags([]); - fakeSearchIndex(); + index = fakeSearchIndex(); }); afterEach(() => { @@ -140,6 +160,25 @@ describe('Cmd-K search', () => { 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'); @@ -204,7 +243,7 @@ describe('Cmd-K search', () => { }); it('sends a billing result for the billing route on screen straight to the billing app', async () => { - const navigateToBillingSubRoute = installEmberBridge(); + const { navigateToBillingSubRoute } = installEmberBridge(); await renderAdminApp('/pro/plans', withBilling()); await openAndSearch('plan'); From 6d424fa3e031a267b4acc737fdf89934ea30fe67 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Fri, 25 Sep 2026 10:32:10 +0200 Subject: [PATCH 07/11] Added tests proving editor session construction starts nothing (#30959) no ref The React editor builds its session in a `useState` initializer, which StrictMode runs twice in development and discards one result without `dispose()`. That is only safe while construction starts nothing. This is now tested. --- .../src/editor/engine/construction.test.ts | 128 +++++++++++++++++ apps/admin/src/editor/session/README.md | 10 +- .../editor-session.construction.test.ts | 130 ++++++++++++++++++ .../src/editor/session/use-editor-session.ts | 2 + 4 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 apps/admin/src/editor/engine/construction.test.ts create mode 100644 apps/admin/src/editor/session/editor-session.construction.test.ts 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, From 4a43a30903f0ea36db034f12a24d0cd6a2866df9 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Fri, 25 Sep 2026 04:49:24 -0400 Subject: [PATCH 08/11] Added email verification for newsletters sent to removed members (#30980) no ref The verification trigger counts members added in the last 30 days, but a member's created event is deleted with the member. A site could add members, send them a newsletter, then delete them (or change their addresses) and repeat, emailing far more people than it ever kept as members without the trigger or the member limit noticing. Newsletter recipients are kept after a member is deleted, so before each newsletter send the trigger now counts recent recipients whose address no longer belongs to a member, and starts verification once that passes `hostSettings.emailVerification.removedRecipientsThreshold`. Matching on address catches changed emails as well as deletions. The check only runs on unverified sites that set the threshold, skips the recipients query when the site hasn't sent that many emails in the window, and stops counting at the threshold, so large sites don't pay for it. --- .../services/email-service/email-service.js | 4 +- .../core/server/services/members/service.js | 29 +++++ .../server/services/verification-trigger.js | 58 +++++++++- .../verification-webhook-service.ts | 2 +- ghost/core/test/e2e-api/admin/members.test.js | 93 +++++++++++++++ .../services/verification-trigger.test.js | 107 ++++++++++++++++++ .../test/utils/email-verification-utils.ts | 3 + 7 files changed, 291 insertions(+), 5 deletions(-) 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/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/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/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, From b4dc42f797c680b6e00e6eda5250267ce10315d0 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Fri, 25 Sep 2026 05:47:37 -0400 Subject: [PATCH 09/11] Changed embed previews to use the public embed renderer by default (#30986) no ref The embed renderer is now deployed to `public.ghostembeds.com`, so sites no longer need to opt in to isolating embed card previews from Admin's origin. - defaults `security.embedPreviewUrl` to `https://public.ghostembeds.com/` - sites can still point at another renderer, or set it to `""` to restore same-origin previews (editors treat an empty value as unset) - config API snapshot updated for the new key --- ghost/core/core/shared/config/defaults.json | 3 ++- .../core/test/e2e-api/admin/__snapshots__/config.test.js.snap | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) 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/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 { From 6fc5dd4c1022b59af6213838c5c14dea338ac334 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Fri, 25 Sep 2026 12:11:37 +0200 Subject: [PATCH 10/11] Added an e2e journey for the React editor's Access section (#30983) no ref Expanded test coverage for the Access sections in the React Editor sidebar. --- .../admin/posts/post/post-settings-sidebar.ts | 9 + .../admin/posts/editor-access-react.test.ts | 248 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 e2e/tests/admin/posts/editor-access-react.test.ts diff --git a/e2e/helpers/pages/admin/posts/post/post-settings-sidebar.ts b/e2e/helpers/pages/admin/posts/post/post-settings-sidebar.ts index 63c6484edc5..7f73079bdd8 100644 --- a/e2e/helpers/pages/admin/posts/post/post-settings-sidebar.ts +++ b/e2e/helpers/pages/admin/posts/post/post-settings-sidebar.ts @@ -340,6 +340,15 @@ class AccessSection extends InlineSection { 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 { 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); + }); +}); From 75e2b7d3125c749e182513f6854491e642f9c709 Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Fri, 25 Sep 2026 12:14:05 +0200 Subject: [PATCH 11/11] Fixed dev data scripts failing in a fresh checkout (#30984) no ref `pnpm reset:data` and related scripts were failing when we removed the build step from `pnpm dev`, meaning fresh worktrees/clones could fail or use dated built files. This resolves that. --- docs/contributing/test-data.md | 3 ++- package.json | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) 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/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",