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
This commit is contained in:
Chris Raible 2023-08-08 15:44:54 -07:00 committed by GitHub
parent d8259fb4fe
commit 9ea4fbd7a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 404 additions and 11 deletions

View File

@ -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();
}

View File

@ -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();
}

View File

@ -45,12 +45,23 @@
{{/unless}}
</a>
{{else}}
<LinkTo @route="editor.edit" @models={{array this.post.displayName this.post.id}} class="permalink gh-list-data gh-post-list-title">
<LinkTo @route={{this.editorRoute}} @models={{array this.post.displayName this.post.id}} class="permalink gh-list-data gh-post-list-title">
<h3 class="gh-content-entry-title">
{{#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}}
<span class="gh-lexical-indicator">L</span>
{{/if}}
{{#if @post.mobiledoc}}
<span class="gh-lexical-indicator">M</span>
{{/if}}
{{/if}}
</h3>
{{#unless @hideAuthor }}
<p class="gh-content-entry-meta">
@ -141,7 +152,7 @@
</span>
</LinkTo>
{{else}}
<LinkTo @route="editor.edit" @models={{array this.post.displayName this.post.id}} class="permalink gh-list-data">
<LinkTo @route={{this.editorRoute}} @models={{array this.post.displayName this.post.id}} class="permalink gh-list-data">
{{!-- Empty on purpose --}}
</LinkTo>
{{/if}}
@ -175,7 +186,7 @@
</span>
</LinkTo>
{{else}}
<LinkTo @route="editor.edit" @models={{array this.post.displayName this.post.id}} class="permalink gh-list-data">
<LinkTo @route={{this.editorRoute}} @models={{array this.post.displayName this.post.id}} class="permalink gh-list-data">
{{!-- Empty on purpose --}}
</LinkTo>
{{/if}}
@ -196,7 +207,7 @@
</span>
</a>
{{else}}
<LinkTo @route="editor.edit" @models={{array this.post.displayName this.post.id}} class="permalink gh-list-data gh-post-list-button" title="">
<LinkTo @route={{this.editorRoute}} @models={{array this.post.displayName this.post.id}} class="permalink gh-list-data gh-post-list-button" title="">
<span class="gh-post-list-cta edit {{if this.isHovered "is-hovered"}}" title="Go to Editor" data-ignore-select>
{{svg-jar "pen" title="Go to Editor"}}
</span>

View File

@ -11,6 +11,8 @@ export default class PostsListItemClicks extends Component {
@tracked isHovered = false;
editorRoute = this.feature.get('convertToLexical') ? 'lexical-editor.edit' : 'editor.edit';
get post() {
return this.args.post;
}

View File

@ -1,7 +1,9 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import {pluralize} from 'ember-inflector';
import {inject as service} from '@ember/service';
export default class EditRoute extends AuthenticatedRoute {
@service feature;
beforeModel(transition) {
super.beforeModel(...arguments);
@ -29,10 +31,15 @@ export default class EditRoute extends AuthenticatedRoute {
};
const records = await this.store.query(modelName, query);
const post = records.firstObject;
let post = records.firstObject;
// CASE: Post is in mobiledoc — convert to lexical or redirect
if (post.mobiledoc) {
return this.router.transitionTo('editor.edit', post);
if (this.feature.get('convertToLexical') && this.feature.get('lexicalEditor')) {
post = await post.save({adapterOptions: {convertToLexical: 1}});
} else {
return this.replaceWith('editor.edit', post);
}
}
return post;

View File

@ -78,6 +78,7 @@ export default class FeatureService extends Service {
@feature('headerUpgrade') headerUpgrade;
@feature('importMemberTier') importMemberTier;
@feature('tipsAndDonations') tipsAndDonations;
@feature('convertToLexical') convertToLexical;
_user = null;

View File

@ -100,7 +100,9 @@
<div class="gh-editor-wordcount">
{{gh-pluralize this.wordCount "word"}}
</div>
{{!-- <a href="https://github.com/TryGhost/Koenig/tree/main/packages/koenig-lexical" target="_blank" rel="noopener noreferrer" class="gh-lexical-indicator">Lexical</a> --}}
{{#if (feature 'convertToLexical')}}
<a href="https://github.com/TryGhost/Koenig/tree/main/packages/koenig-lexical" target="_blank" rel="noopener noreferrer" class="gh-lexical-indicator">Lexical</a>
{{/if}}
<a href="https://ghost.org/help/using-the-editor/" class="flex" target="_blank" rel="noopener noreferrer">{{svg-jar "help"}}</a>
</div>

View File

@ -339,6 +339,20 @@
</div>
</div>
<div class="gh-expandable-block">
<div class="gh-expandable-header">
<div>
<h4 class="gh-expandable-title">Convert to Lexical</h4>
<p class="gh-expandable-description">
Convert mobiledoc posts to lexical upon opening in the editor.
</p>
</div>
<div class="for-switch">
<GhFeatureFlag @flag="convertToLexical" />
</div>
</div>
</div>
<div class="gh-expandable-block">
<div class="gh-expandable-header">
<div>

View File

@ -141,6 +141,7 @@ module.exports = {
'source',
'force_rerender',
'save_revision',
'convert_to_lexical',
// NOTE: only for internal context
'forUpdate',
'transacting'

View File

@ -192,6 +192,7 @@ module.exports = {
'newsletter',
'force_rerender',
'save_revision',
'convert_to_lexical',
// NOTE: only for internal context
'forUpdate',
'transacting'

View File

@ -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

View File

@ -41,8 +41,9 @@ const ALPHA_FEATURES = [
'mailEvents',
'collectionsCard',
'headerUpgrade',
'tipsAndDonations',
'importMemberTier',
'tipsAndDonations'
'convertToLexical'
];
module.exports.GA_KEYS = [...GA_FEATURES];

View File

@ -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",

View File

@ -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<Array>,
"canonical_url": null,
"codeinjection_foot": null,
"codeinjection_head": null,
"comment_id": Any<String>,
"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": "<p>This is some great content.</p>",
"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<Array>,
"primary_author": Any<Object>,
"primary_tag": Any<Object>,
"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<Boolean>,
"slug": "test-post",
"status": "published",
"tags": Any<Array>,
"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<String>,
"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 [

View File

@ -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<Array>,
"canonical_url": null,
"codeinjection_foot": null,
"codeinjection_head": null,
"comment_id": Any<String>,
"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": "<p>This is some great content.</p>",
"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<Array>,
"primary_author": Any<Object>,
"primary_tag": Any<Object>,
"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<Array>,
"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<String>,
"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 [

View File

@ -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
});
});
});
});

View File

@ -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
});
});
});
});