From 9ea4fbd7a7dca3ffdc8cabd4aae903c700decd95 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Tue, 8 Aug 2023 15:44:54 -0700 Subject: [PATCH] Added feature to convert and open mobiledoc posts in the lexical editor (#17453) refs TryGhost/Product#3638 - Added `convert_to_lexical` flag to the posts/pages edit endpoint - Added 'convertToLexical' feature flag so we can enable/disable this feature independently from the main lexical beta flag - Modified admin posts/pages list to point to the lexical editor for _all_ posts, regardless of mobiledoc vs lexical (if the flag is on) - Added call to edit endpoint with `convert_to_lexical` in the lexical editor admin route if the page/post is currently in mobiledoc and the flag is enabled --- ghost/admin/app/adapters/page.js | 5 + ghost/admin/app/adapters/post.js | 6 + .../app/components/posts-list/list-item.hbs | 19 ++- .../app/components/posts-list/list-item.js | 2 + ghost/admin/app/routes/lexical-editor/edit.js | 15 ++- ghost/admin/app/services/feature.js | 1 + ghost/admin/app/templates/lexical-editor.hbs | 4 +- ghost/admin/app/templates/settings/labs.hbs | 14 +++ ghost/core/core/server/api/endpoints/pages.js | 1 + ghost/core/core/server/api/endpoints/posts.js | 1 + ghost/core/core/server/models/post.js | 15 ++- ghost/core/core/shared/labs.js | 3 +- ghost/core/package.json | 1 + .../admin/__snapshots__/pages.test.js.snap | 107 +++++++++++++++++ .../admin/__snapshots__/posts.test.js.snap | 110 ++++++++++++++++++ ghost/core/test/e2e-api/admin/pages.test.js | 75 ++++++++++++ ghost/core/test/e2e-api/admin/posts.test.js | 36 ++++++ 17 files changed, 404 insertions(+), 11 deletions(-) diff --git a/ghost/admin/app/adapters/page.js b/ghost/admin/app/adapters/page.js index aa2914184c..568b33cb8b 100644 --- a/ghost/admin/app/adapters/page.js +++ b/ghost/admin/app/adapters/page.js @@ -11,6 +11,11 @@ export default class Page extends ApplicationAdapter { parsedUrl.searchParams.append('save_revision', saveRevision); } + if (snapshot?.adapterOptions?.convertToLexical) { + const convertToLexical = snapshot.adapterOptions.convertToLexical; + parsedUrl.searchParams.append('convert_to_lexical', convertToLexical); + } + return parsedUrl.toString(); } diff --git a/ghost/admin/app/adapters/post.js b/ghost/admin/app/adapters/post.js index 4d76af9ce4..940b4a5718 100644 --- a/ghost/admin/app/adapters/post.js +++ b/ghost/admin/app/adapters/post.js @@ -24,6 +24,12 @@ export default class Post extends ApplicationAdapter { const saveRevision = snapshot.adapterOptions.saveRevision; parsedUrl.searchParams.append('save_revision', saveRevision); } + + if (snapshot?.adapterOptions?.convertToLexical) { + const convertToLexical = snapshot.adapterOptions.convertToLexical; + parsedUrl.searchParams.append('convert_to_lexical', convertToLexical); + } + return parsedUrl.toString(); } diff --git a/ghost/admin/app/components/posts-list/list-item.hbs b/ghost/admin/app/components/posts-list/list-item.hbs index 7022eba69f..42f5077b76 100644 --- a/ghost/admin/app/components/posts-list/list-item.hbs +++ b/ghost/admin/app/components/posts-list/list-item.hbs @@ -45,12 +45,23 @@ {{/unless}} {{else}} - +

{{#if @post.featured}} {{svg-jar "star-fill" class="gh-featured-post"}} {{/if}} {{@post.title}} + + {{! Display lexical/mobiledoc indicators for easier testing of the feature --}} + {{#if (feature 'convertToLexical')}} + {{#if @post.lexical}} + L + {{/if}} + {{#if @post.mobiledoc}} + M + {{/if}} + {{/if}} +

{{#unless @hideAuthor }}
{{gh-pluralize this.wordCount "word"}}
- {{!-- Lexical --}} + {{#if (feature 'convertToLexical')}} + Lexical + {{/if}} {{svg-jar "help"}} diff --git a/ghost/admin/app/templates/settings/labs.hbs b/ghost/admin/app/templates/settings/labs.hbs index b590329937..f7d00683ff 100644 --- a/ghost/admin/app/templates/settings/labs.hbs +++ b/ghost/admin/app/templates/settings/labs.hbs @@ -339,6 +339,20 @@ +
+
+
+

Convert to Lexical

+

+ Convert mobiledoc posts to lexical upon opening in the editor. +

+
+
+ +
+
+
+
diff --git a/ghost/core/core/server/api/endpoints/pages.js b/ghost/core/core/server/api/endpoints/pages.js index f33965e1e6..bd5661a93b 100644 --- a/ghost/core/core/server/api/endpoints/pages.js +++ b/ghost/core/core/server/api/endpoints/pages.js @@ -141,6 +141,7 @@ module.exports = { 'source', 'force_rerender', 'save_revision', + 'convert_to_lexical', // NOTE: only for internal context 'forUpdate', 'transacting' diff --git a/ghost/core/core/server/api/endpoints/posts.js b/ghost/core/core/server/api/endpoints/posts.js index 79a43947f2..cc71f529e8 100644 --- a/ghost/core/core/server/api/endpoints/posts.js +++ b/ghost/core/core/server/api/endpoints/posts.js @@ -192,6 +192,7 @@ module.exports = { 'newsletter', 'force_rerender', 'save_revision', + 'convert_to_lexical', // NOTE: only for internal context 'forUpdate', 'transacting' diff --git a/ghost/core/core/server/models/post.js b/ghost/core/core/server/models/post.js index 9300e3086d..5996ab88af 100644 --- a/ghost/core/core/server/models/post.js +++ b/ghost/core/core/server/models/post.js @@ -19,6 +19,8 @@ const {Tag} = require('./tag'); const {Newsletter} = require('./newsletter'); const {BadRequestError} = require('@tryghost/errors'); const {PostRevisions} = require('@tryghost/post-revisions'); +const {mobiledocToLexical} = require('@tryghost/kg-converters'); +const labs = require('../../shared/labs'); const messages = { isAlreadyPublished: 'Your post is already published, please reload your page.', @@ -913,6 +915,16 @@ Post = ghostBookshelf.Model.extend({ }); } + // CASE: Convert post to lexical on the fly + if (labs.isSet('convertToLexical') && labs.isSet('lexicalEditor') && options.convert_to_lexical) { + ops.push(async function convertToLexical() { + const mobiledoc = model.get('mobiledoc'); + const lexical = mobiledocToLexical(mobiledoc); + model.set('lexical', lexical); + model.set('mobiledoc', null); + }); + } + if (this.get('tiers')) { this.set('tiers', this.get('tiers').map(t => ({ id: t.id @@ -1154,9 +1166,10 @@ Post = ghostBookshelf.Model.extend({ const validOptions = { findOne: ['columns', 'importing', 'withRelated', 'require', 'filter'], findPage: ['status'], + findAll: ['columns', 'filter'], destroy: ['destroyAll', 'destroyBy'], - edit: ['filter', 'email_segment', 'force_rerender', 'newsletter', 'save_revision'] + edit: ['filter', 'email_segment', 'force_rerender', 'newsletter', 'save_revision', 'convert_to_lexical'] }; // The post model additionally supports having a formats option diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index ebf1b97c83..c3fef8721f 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -41,8 +41,9 @@ const ALPHA_FEATURES = [ 'mailEvents', 'collectionsCard', 'headerUpgrade', + 'tipsAndDonations', 'importMemberTier', - 'tipsAndDonations' + 'convertToLexical' ]; module.exports.GA_KEYS = [...GA_FEATURES]; diff --git a/ghost/core/package.json b/ghost/core/package.json index b71ae5b4d4..6d1a677c22 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -101,6 +101,7 @@ "@tryghost/importer-revue": "0.0.0", "@tryghost/job-manager": "0.0.0", "@tryghost/kg-card-factory": "4.0.9", + "@tryghost/kg-converters": "0.0.7", "@tryghost/kg-default-atoms": "4.0.2", "@tryghost/kg-default-cards": "9.1.2", "@tryghost/kg-default-nodes": "0.1.17", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snap index 6d83d0d6a9..99d315ef0f 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/pages.test.js.snap @@ -1,5 +1,112 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Pages API Convert can convert a mobiledoc page to lexical 1: [body] 1`] = ` +Object { + "pages": Array [ + Object { + "authors": Any, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": Any, + "count": Object { + "negative_feedback": 0, + "paid_conversions": 0, + "positive_feedback": 0, + "signups": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "excerpt": "This is some great content.", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

This is some great content.

", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "lexical": "{\\"root\\":{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"This is some great content.\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", + "meta_description": null, + "meta_title": null, + "mobiledoc": null, + "og_description": null, + "og_image": null, + "og_title": null, + "post_revisions": Any, + "primary_author": Any, + "primary_tag": Any, + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "reading_time": 0, + "show_title_and_feature_image": Any, + "slug": "test-post", + "status": "published", + "tags": Any, + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + ], + "title": "Test Post", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + ], +} +`; + +exports[`Pages API Convert can convert a mobiledoc page to lexical 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "4013", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + exports[`Pages API Copy Can copy a page 1: [body] 1`] = ` Object { "pages": Array [ diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap index dc97a6b056..bc961df4ea 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap @@ -790,6 +790,116 @@ Object { } `; +exports[`Posts API Convert can convert a mobiledoc post to lexical 1: [body] 1`] = ` +Object { + "posts": Array [ + Object { + "authors": Any, + "canonical_url": null, + "codeinjection_foot": null, + "codeinjection_head": null, + "comment_id": Any, + "count": Object { + "clicks": 0, + "negative_feedback": 0, + "positive_feedback": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "custom_excerpt": null, + "custom_template": null, + "email": null, + "email_only": false, + "email_segment": "all", + "email_subject": null, + "excerpt": "This is some great content.", + "feature_image": null, + "feature_image_alt": null, + "feature_image_caption": null, + "featured": false, + "frontmatter": null, + "html": "

This is some great content.

", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "lexical": "{\\"root\\":{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"This is some great content.\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1}],\\"direction\\":\\"ltr\\",\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", + "meta_description": null, + "meta_title": null, + "mobiledoc": null, + "newsletter": null, + "og_description": null, + "og_image": null, + "og_title": null, + "post_revisions": Any, + "primary_author": Any, + "primary_tag": Any, + "published_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "reading_time": 0, + "slug": "test-post-2", + "status": "published", + "tags": Any, + "tiers": Array [ + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": null, + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": null, + "monthly_price_id": null, + "name": "Free", + "slug": "free", + "trial_days": 0, + "type": "free", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": null, + "yearly_price_id": null, + }, + Object { + "active": true, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "currency": "usd", + "description": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "monthly_price": 500, + "monthly_price_id": null, + "name": "Default Product", + "slug": "default-product", + "trial_days": 0, + "type": "paid", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "visibility": "public", + "welcome_page_url": null, + "yearly_price": 5000, + "yearly_price_id": null, + }, + ], + "title": "Test Post", + "twitter_description": null, + "twitter_image": null, + "twitter_title": null, + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": Any, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "public", + }, + ], +} +`; + +exports[`Posts API Convert can convert a mobiledoc post to lexical 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "4050", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + exports[`Posts API Copy Can copy a post 1: [body] 1`] = ` Object { "posts": Array [ diff --git a/ghost/core/test/e2e-api/admin/pages.test.js b/ghost/core/test/e2e-api/admin/pages.test.js index 51f27373e1..bed4220aaf 100644 --- a/ghost/core/test/e2e-api/admin/pages.test.js +++ b/ghost/core/test/e2e-api/admin/pages.test.js @@ -113,4 +113,79 @@ describe('Pages API', function () { }); }); }); + + describe('Convert', function () { + it('can convert a mobiledoc page to lexical', async function () { + const mobiledoc = JSON.stringify({ + version: '0.3.1', + ghostVersion: '4.0', + markups: [], + atoms: [], + cards: [], + sections: [ + [1, 'p', [ + [0, [], 0, 'This is some great content.'] + ]] + ] + }); + const expectedLexical = JSON.stringify({ + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'This is some great content.', + type: 'text', + version: 1 + } + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1 + } + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1 + } + }); + const pageData = { + title: 'Test Post', + status: 'published', + mobiledoc: mobiledoc, + lexical: null + }; + + const {body: pageBody} = await agent + .post('/pages/?formats=mobiledoc,lexical,html', { + headers: { + 'content-type': 'application/json' + } + }) + .body({pages: [pageData]}) + .expectStatus(201); + + const [pageResponse] = pageBody.pages; + + await agent + .put(`/pages/${pageResponse.id}/?formats=mobiledoc,lexical,html&convert_to_lexical=true`) + .body({pages: [Object.assign({}, pageResponse)]}) + .expectStatus(200) + .matchBodySnapshot({ + pages: [Object.assign({}, matchPageShallowIncludes, {lexical: expectedLexical, mobiledoc: null})] + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + }); + }); }); diff --git a/ghost/core/test/e2e-api/admin/posts.test.js b/ghost/core/test/e2e-api/admin/posts.test.js index a78658ee33..c7d40e2d3f 100644 --- a/ghost/core/test/e2e-api/admin/posts.test.js +++ b/ghost/core/test/e2e-api/admin/posts.test.js @@ -628,4 +628,40 @@ describe('Posts API', function () { }); }); }); + + describe('Convert', function () { + it('can convert a mobiledoc post to lexical', async function () { + const mobiledoc = createMobiledoc('This is some great content.'); + const expectedLexical = createLexical('This is some great content.'); + const postData = { + title: 'Test Post', + status: 'published', + mobiledoc: mobiledoc, + lexical: null + }; + + const {body} = await agent + .post('/posts/?formats=mobiledoc,lexical,html', { + headers: { + 'content-type': 'application/json' + } + }) + .body({posts: [postData]}) + .expectStatus(201); + + const [postResponse] = body.posts; + + await agent + .put(`/posts/${postResponse.id}/?formats=mobiledoc,lexical,html&convert_to_lexical=true`) + .body({posts: [Object.assign({}, postResponse)]}) + .expectStatus(200) + .matchBodySnapshot({ + posts: [Object.assign({}, matchPostShallowIncludes, {lexical: expectedLexical, mobiledoc: null})] + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + }); + }); });