feat: add prePublish,postPublish events (#3172)

This commit is contained in:
Erez Rokah 2020-01-31 16:44:01 -08:00 committed by GitHub
parent fd9e2c89f2
commit 0d7e36ba79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 218 additions and 9 deletions

View File

@ -6,7 +6,12 @@ import { ThunkDispatch } from 'redux-thunk';
import { Map, List } from 'immutable';
import { serializeValues } from '../lib/serializeEntryValues';
import { currentBackend } from '../backend';
import { selectPublishedSlugs, selectUnpublishedSlugs, selectEntry } from '../reducers';
import {
selectPublishedSlugs,
selectUnpublishedSlugs,
selectEntry,
selectUnpublishedEntry,
} from '../reducers';
import { selectFields } from '../reducers/collections';
import { EDITORIAL_WORKFLOW, status, Status } from '../constants/publishModes';
import { EDITORIAL_WORKFLOW_ERROR } from 'netlify-cms-lib-util';
@ -513,9 +518,10 @@ export function publishUnpublishedEntry(collection: string, slug: string) {
const collections = state.collections;
const backend = currentBackend(state.config);
const transactionID = uuid();
const entry = selectUnpublishedEntry(state, collection, slug);
dispatch(unpublishedEntryPublishRequest(collection, slug, transactionID));
return backend
.publishUnpublishedEntry(collection, slug)
.publishUnpublishedEntry(entry)
.then(() => {
// re-load media after entry was published
dispatch(loadMedia());

View File

@ -16,7 +16,7 @@ import {
} from './reducers/collections';
import { createEntry, EntryValue } from './valueObjects/Entry';
import { sanitizeChar } from './lib/urlHelper';
import { getBackend } from './lib/registry';
import { getBackend, invokeEvent } from './lib/registry';
import { commitMessageFormatter, slugFormatter, previewUrlFormatter } from './lib/formatters';
import {
localForage,
@ -737,7 +737,27 @@ export class Backend {
...updatedOptions,
};
return this.implementation.persistEntry(entryObj, assetProxies, opts).then(() => entryObj.slug);
if (!useWorkflow) {
await this.invokePrePublishEvent(entryDraft.get('entry'));
}
await this.implementation.persistEntry(entryObj, assetProxies, opts);
if (!useWorkflow) {
await this.invokePostPublishEvent(entryDraft.get('entry'));
}
return entryObj.slug;
}
async invokePrePublishEvent(entry: EntryMap) {
const { login, name } = (await this.currentUser()) as User;
await invokeEvent({ name: 'prePublish', data: { entry, author: { login, name } } });
}
async invokePostPublishEvent(entry: EntryMap) {
const { login, name } = (await this.currentUser()) as User;
await invokeEvent({ name: 'postPublish', data: { entry, author: { login, name } } });
}
async persistMedia(config: Config, file: AssetProxy) {
@ -803,8 +823,13 @@ export class Backend {
return this.implementation.updateUnpublishedEntryStatus!(collection, slug, newStatus);
}
publishUnpublishedEntry(collection: string, slug: string) {
return this.implementation.publishUnpublishedEntry!(collection, slug);
async publishUnpublishedEntry(entry: EntryMap) {
const collection = entry.get('collection');
const slug = entry.get('slug');
await this.invokePrePublishEvent(entry);
await this.implementation.publishUnpublishedEntry!(collection, slug);
await this.invokePostPublishEvent(entry);
}
deleteUnpublishedEntry(collection: string, slug: string) {

View File

@ -1,5 +1,3 @@
import { registerLocale, getLocale } from '../registry';
jest.spyOn(console, 'error').mockImplementation(() => {});
describe('registry', () => {
@ -10,6 +8,8 @@ describe('registry', () => {
describe('registerLocale', () => {
it('should log error when name is empty', () => {
const { registerLocale } = require('../registry');
registerLocale();
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(
@ -18,6 +18,8 @@ describe('registry', () => {
});
it('should log error when phrases are undefined', () => {
const { registerLocale } = require('../registry');
registerLocale('fr');
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(
@ -26,6 +28,8 @@ describe('registry', () => {
});
it('should register locale', () => {
const { registerLocale, getLocale } = require('../registry');
const phrases = {
app: {
header: {
@ -39,4 +43,108 @@ describe('registry', () => {
expect(getLocale('de')).toBe(phrases);
});
});
describe('eventHandlers', () => {
const events = ['prePublish', 'postPublish'];
describe('registerEventListener', () => {
it('should throw error on invalid event', () => {
const { registerEventListener } = require('../registry');
expect(() => registerEventListener({ name: 'unknown' })).toThrow(
new Error("Invalid event name 'unknown'"),
);
});
events.forEach(name => {
it(`should register '${name}' event`, () => {
const { registerEventListener, getEventListeners } = require('../registry');
const handler = jest.fn();
registerEventListener({ name, handler });
expect(getEventListeners(name)).toEqual([{ handler, options: {} }]);
});
});
});
describe('removeEventListener', () => {
it('should throw error on invalid event', () => {
const { removeEventListener } = require('../registry');
expect(() => removeEventListener({ name: 'unknown' })).toThrow(
new Error("Invalid event name 'unknown'"),
);
});
events.forEach(name => {
it(`should remove '${name}' event by handler`, () => {
const {
registerEventListener,
getEventListeners,
removeEventListener,
} = require('../registry');
const handler1 = jest.fn();
const handler2 = jest.fn();
registerEventListener({ name, handler: handler1 });
registerEventListener({ name, handler: handler2 });
expect(getEventListeners(name)).toHaveLength(2);
removeEventListener({ name, handler: handler1 });
expect(getEventListeners(name)).toEqual([{ handler: handler2, options: {} }]);
});
});
events.forEach(name => {
it(`should remove '${name}' event by name`, () => {
const {
registerEventListener,
getEventListeners,
removeEventListener,
} = require('../registry');
const handler1 = jest.fn();
const handler2 = jest.fn();
registerEventListener({ name, handler: handler1 });
registerEventListener({ name, handler: handler2 });
expect(getEventListeners(name)).toHaveLength(2);
removeEventListener({ name });
expect(getEventListeners(name)).toHaveLength(0);
});
});
});
describe('invokeEvent', () => {
it('should throw error on invalid event', async () => {
const { invokeEvent } = require('../registry');
await expect(invokeEvent({ name: 'unknown', data: {} })).rejects.toThrow(
new Error("Invalid event name 'unknown'"),
);
});
events.forEach(name => {
it(`should invoke '${name}' event with data`, async () => {
const { registerEventListener, invokeEvent } = require('../registry');
const options = { hello: 'world' };
const handler = jest.fn();
registerEventListener({ name, handler }, options);
const data = { entry: {} };
await invokeEvent({ name, data });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(data, options);
});
});
});
});
});

View File

@ -3,6 +3,12 @@ import produce from 'immer';
import { oneLine } from 'common-tags';
import EditorComponent from 'ValueObjects/EditorComponent';
const allowedEvents = ['prePublish', 'postPublish'];
const eventHandlers = {};
allowedEvents.forEach(e => {
eventHandlers[e] = [];
});
/**
* Global Registry Object
*/
@ -15,6 +21,7 @@ const registry = {
widgetValueSerializers: {},
mediaLibraries: [],
locales: {},
eventHandlers,
};
export default {
@ -36,6 +43,10 @@ export default {
getMediaLibrary,
registerLocale,
getLocale,
registerEventListener,
removeEventListener,
getEventListeners,
invokeEvent,
};
/**
@ -181,6 +192,45 @@ export function getMediaLibrary(name) {
return registry.mediaLibraries.find(ml => ml.name === name);
}
function validateEventName(name) {
if (!allowedEvents.includes(name)) {
throw new Error(`Invalid event name '${name}'`);
}
}
export function getEventListeners(name) {
validateEventName(name);
return [...registry.eventHandlers[name]];
}
export function registerEventListener({ name, handler }, options = {}) {
validateEventName(name);
registry.eventHandlers[name].push({ handler, options });
}
export async function invokeEvent({ name, data }) {
validateEventName(name);
const handlers = registry.eventHandlers[name];
for (const { handler, options } of handlers) {
try {
await handler(data, options);
} catch (e) {
console.warn(`Failed running handler for event ${name} with message: ${e.message}`);
}
}
}
export function removeEventListener({ name, handler }) {
validateEventName(name);
if (handler) {
registry.eventHandlers[name] = registry.eventHandlers[name].filter(
item => item.handler !== handler,
);
} else {
registry.eventHandlers[name] = [];
}
}
/**
* Locales
*/

View File

@ -27,7 +27,7 @@ backend:
> `netlify-cms-proxy-server` runs an unauthenticated express server. As any client can send requests to the server, it should only be used for local development.
## GitLab and BitBucket editorial workflow support
## GitLab and BitBucket Editorial Workflow Support
You can enable the Editorial Workflow with the following line in your Netlify CMS `config.yml` file:
@ -377,3 +377,23 @@ Example config:
config:
max_file_size: 512000 # in bytes, only for default media library
```
## Registering to CMS Events
You can execute a function when a specific CMS event occurs.
Example usage:
```javascript
CMS.registerEventListener({
name: 'prePublish',
handler: ({ author, entry }) => console.log(JSON.stringify({ author, data: entry.get('data') })),
});
CMS.registerEventListener({
name: 'postPublish',
handler: ({ author, entry }) => console.log(JSON.stringify({ author, data: entry.get('data') })),
});
```
> Supported events are `prePublish` and `postPublish`