Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions packages/kg-default-nodes/lib/generate-decorator-node.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {KoenigDecoratorNode} from './KoenigDecoratorNode';
import readTextContent from './utils/read-text-content';
import {ALL_MEMBERS_SEGMENT, usesOldVisibilityFormat} from './utils/visibility';
import {ALL_MEMBERS_SEGMENT, buildDefaultVisibility, migrateOldVisibilityFormat, usesOldVisibilityFormat} from './utils/visibility';
/**
* Validates the required arguments passed to `generateDecoratorNode`
*/
Expand Down Expand Up @@ -36,9 +36,10 @@ function validateArguments(nodeType, properties) {
*
* @param {string} nodeType – The node's type (must be unique)
* @param {DecoratorNodeProperty[]} properties - An array of properties for the generated class
* @param {boolean} hasVisibility - Whether to add a visibility property to the node
* @returns {Object} - The generated class.
*/
export function generateDecoratorNode({nodeType, properties = [], version = 1}) {
export function generateDecoratorNode({nodeType, properties = [], version = 1, hasVisibility = false}) {
validateArguments(nodeType, properties);

// Adds a `privateName` field to the properties for convenience (e.g. `__name`):
Expand All @@ -47,6 +48,18 @@ export function generateDecoratorNode({nodeType, properties = [], version = 1})
return {...prop, privateName: `__${prop.name}`};
});

// Adds `visibility` property to the properties array if `hasVisibility` is true
// uses a getter for `default` to avoid problems with mutation of nested objects
if (hasVisibility) {
properties.push({
name: 'visibility',
get default() {
return buildDefaultVisibility();
},
privateName: '__visibility'
});
}

class GeneratedDecoratorNode extends KoenigDecoratorNode {
constructor(data = {}, key) {
super(key);
Expand Down Expand Up @@ -134,6 +147,12 @@ export function generateDecoratorNode({nodeType, properties = [], version = 1})
static importJSON(serializedNode) {
const data = {};

// migrate older nodes that were saved with an earlier version of the visibility format
const visibility = serializedNode.visibility;
if (visibility && usesOldVisibilityFormat(visibility)) {
migrateOldVisibilityFormat(visibility);
}

properties.forEach((prop) => {
data[prop.name] = serializedNode[prop.name];
});
Expand Down
3 changes: 3 additions & 0 deletions packages/kg-default-nodes/lib/kg-default-nodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@ export * from './nodes/TKNode';
export * from './nodes/at-link/index.js';
export * from './nodes/zwnj/ZWNJNode';

// export utility functions that are useful in other packages or tests
import * as visibilityUtils from './utils/visibility';
import {generateDecoratorNode} from './generate-decorator-node.js';
export const utils = {
generateDecoratorNode,
visibility: visibilityUtils
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// eslint-disable-next-line ghost/filenames/match-exported-class
import {generateDecoratorNode} from '../../generate-decorator-node';

export class CallToActionNode extends generateDecoratorNode({nodeType: 'call-to-action',
export class CallToActionNode extends generateDecoratorNode({
nodeType: 'call-to-action',
hasVisibility: true,
properties: [
{name: 'layout', default: 'minimal'},
{name: 'textValue', default: '', wordCount: true},
Expand All @@ -14,8 +16,8 @@ export class CallToActionNode extends generateDecoratorNode({nodeType: 'call-to-
{name: 'backgroundColor', default: 'grey'},
{name: 'hasImage', default: false},
{name: 'imageUrl', default: ''}
]}
) {
]
}) {
/* overrides */
}

Expand Down
25 changes: 2 additions & 23 deletions packages/kg-default-nodes/lib/nodes/html/HtmlNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,13 @@
import {generateDecoratorNode} from '../../generate-decorator-node';
import {renderHtmlNode} from './html-renderer';
import {parseHtmlNode} from './html-parser';
import {DEFAULT_VISIBILITY, usesOldVisibilityFormat, migrateOldVisibilityFormat} from '../../utils/visibility';

export class HtmlNode extends generateDecoratorNode({nodeType: 'html',
hasVisibility: true,
properties: [
{name: 'html', default: '', urlType: 'html', wordCount: true},
{name: 'visibility', default: {...DEFAULT_VISIBILITY}}
{name: 'html', default: '', urlType: 'html', wordCount: true}
]}
) {
constructor({
html = '',
visibility = {...DEFAULT_VISIBILITY}
} = {}, key) {
super(key);
this.html = html;
this.visibility = visibility;
}

static importJSON(serializedNode) {
const {visibility} = serializedNode;

// migrate older nodes that were saved with an earlier version of the visibility format
if (visibility && usesOldVisibilityFormat(visibility)) {
migrateOldVisibilityFormat(visibility);
}

return super.importJSON(serializedNode);
}

static importDOM() {
return parseHtmlNode(this);
}
Expand Down
7 changes: 6 additions & 1 deletion packages/kg-default-nodes/lib/utils/visibility.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export const ALL_MEMBERS_SEGMENT = 'status:free,status:-free';
export const NO_MEMBERS_SEGMENT = '';

export const DEFAULT_VISIBILITY = {
const DEFAULT_VISIBILITY = {
web: {
nonMember: true,
memberSegment: 'status:free,status:-free'
Expand All @@ -11,6 +11,11 @@ export const DEFAULT_VISIBILITY = {
}
};

// ensure we always work with a deep copy to avoid accidental constant mutations
export function buildDefaultVisibility() {
return JSON.parse(JSON.stringify(DEFAULT_VISIBILITY));
}

export function usesOldVisibilityFormat(visibility) {
return !Object.prototype.hasOwnProperty.call(visibility, 'web')
|| !Object.prototype.hasOwnProperty.call(visibility, 'email')
Expand Down
129 changes: 129 additions & 0 deletions packages/kg-default-nodes/test/generate-decorator-node.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
const {createHeadlessEditor} = require('@lexical/headless');
const {utils} = require('../');

const defaultVisibility = utils.visibility.buildDefaultVisibility();

describe('Utils: generateDecoratorNode', function () {
let editor;

// NOTE: all tests should use this function, without it you need manual
// try/catch and done handling to avoid assertion failures not triggering
// failed tests
const editorTest = testFn => function (done) {
editor.update(() => {
try {
testFn();
done();
} catch (e) {
done(e);
}
});
};

describe('hasVisibility', function () {
let NodeWithVisibility;
let $createNodeWithVisibility;

before(function () {
NodeWithVisibility = utils.generateDecoratorNode({
nodeType: 'visibility-test',
properties: [],
hasVisibility: true
});

$createNodeWithVisibility = (dataset) => {
return new NodeWithVisibility(dataset);
};

editor = createHeadlessEditor({nodes: [NodeWithVisibility]});
});

it('adds visibility property with default', editorTest(function () {
const node = $createNodeWithVisibility();

node.visibility.should.deepEqual(defaultVisibility, 'node.visibility');
node.getDataset().visibility.should.deepEqual(defaultVisibility, 'node.getDataset().visibility');
node.exportJSON().visibility.should.deepEqual(defaultVisibility, 'node.exportJSON().visibility');
}));

it('can update visibility', editorTest(function () {
const node = $createNodeWithVisibility();

const newVisibility = {
web: {
nonMember: false,
memberSegment: 'status:free'
},
email: {
memberSegment: 'status:free'
}
};

node.visibility = newVisibility;

node.visibility.should.deepEqual(newVisibility, 'node.visibility');
node.getDataset().visibility.should.deepEqual(newVisibility, 'node.getDataset().visibility');
node.exportJSON().visibility.should.deepEqual(newVisibility, 'node.exportJSON().visibility');
}));

it('ensures default doesn\'t change when nested visibility objects are updated', editorTest(function () {
const node = $createNodeWithVisibility();

// NOTE: this wouldn't trigger a Lexical node update, it's just to show
// that the default can't be accidentally changed by reference
node.visibility.web.nonMember = false;

NodeWithVisibility.getPropertyDefaults().visibility.should.deepEqual(defaultVisibility);
}));

// During the early visibility beta period we had a different format for visibility
// when importing we convert to the new format so it keeps working with later UI iterations
it('migrates old visibility format when importing JSON', editorTest(function () {
const node = NodeWithVisibility.importJSON({
visibility: {
showOnWeb: false,
showOnEmail: true,
segment: 'status:free'
}
});

// old values are kept, new values are added
node.visibility.should.deepEqual({
showOnWeb: false,
showOnEmail: true,
segment: 'status:free',
web: {
nonMember: false,
memberSegment: ''
},
email: {
memberSegment: 'status:free'
}
});
}));

it('can set visibility via constructor', editorTest(function () {
const node = $createNodeWithVisibility({
visibility: {
web: {
nonMember: false,
memberSegment: 'status:free'
},
email: {
memberSegment: 'status:free'
}
}
});

node.visibility.should.deepEqual({
web: {
nonMember: false,
memberSegment: 'status:free'
},
email: {
memberSegment: 'status:free'
}
});
}));
});
});
30 changes: 26 additions & 4 deletions packages/kg-default-nodes/test/nodes/call-to-action.test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
const {dom} = require('../test-utils');

const {createHeadlessEditor} = require('@lexical/headless');

const {CallToActionNode, $isCallToActionNode} = require('../../');
const {CallToActionNode, $isCallToActionNode, utils} = require('../../');

const editorNodes = [CallToActionNode];

Expand Down Expand Up @@ -65,6 +63,7 @@ describe('CallToActionNode', function () {
callToActionNode.backgroundColor.should.equal(dataset.backgroundColor);
callToActionNode.hasImage.should.equal(dataset.hasImage);
callToActionNode.imageUrl.should.equal(dataset.imageUrl);
callToActionNode.visibility.should.deepEqual(utils.visibility.buildDefaultVisibility());
}));

it('has setters for all properties', editorTest(function () {
Expand Down Expand Up @@ -108,17 +107,40 @@ describe('CallToActionNode', function () {

callToActionNode.hasImage.should.equal(false);
callToActionNode.hasImage = true;
callToActionNode.hasImage.should.equal(true);

callToActionNode.imageUrl.should.equal('');
callToActionNode.imageUrl = 'http://blog.com/image1.jpg';
callToActionNode.imageUrl.should.equal('http://blog.com/image1.jpg');

callToActionNode.visibility.should.deepEqual(utils.visibility.buildDefaultVisibility());
callToActionNode.visibility = {
web: {
nonMember: false,
memberSegment: ''
},
email: {
memberSegment: ''
}
};
callToActionNode.visibility.should.deepEqual({
web: {
nonMember: false,
memberSegment: ''
},
email: {
memberSegment: ''
}
});
}));

it('has getDataset() convenience method', editorTest(function () {
const callToActionNode = new CallToActionNode(dataset);
const callToActionNodeDataset = callToActionNode.getDataset();

callToActionNodeDataset.should.deepEqual({
...dataset
...dataset,
...{visibility: utils.visibility.buildDefaultVisibility()}
});
}));
});
Expand Down
5 changes: 2 additions & 3 deletions packages/koenig-lexical/src/utils/visibility.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import {utils} from '@tryghost/kg-default-nodes';

const DEFAULT_VISIBILITY = utils.visibility.DEFAULT_VISIBILITY;

export function parseVisibilityToToggles(visibility) {
return {
web: {
Expand Down Expand Up @@ -45,7 +43,8 @@ export function serializeTogglesToVisibility(toggles) {
}

// used for building UI
export function getVisibilityOptions(visibility = DEFAULT_VISIBILITY, {isStripeEnabled = true} = {}) {
export function getVisibilityOptions(visibility, {isStripeEnabled = true} = {}) {
visibility = visibility || utils.visibility.buildDefaultVisibility();
const toggles = parseVisibilityToToggles(visibility);

// use arrays to ensure consistent order when using to build UI
Expand Down
Loading