From 6dc9dbfe7373cf8c34c6abf523ca3aa34e16601a Mon Sep 17 00:00:00 2001 From: Chris Helgert Date: Tue, 29 Aug 2023 09:03:38 +0200 Subject: [PATCH] feat(live-preview): add functionality to subscribe to the save event of an entity --- .../src/__tests__/index.spec.ts | 10 ++- .../src/__tests__/liveUpdates.spec.ts | 66 +++++++++++++++---- .../src/helpers/validation.ts | 31 +++++++-- packages/live-preview-sdk/src/index.ts | 38 ++++++++++- packages/live-preview-sdk/src/liveUpdates.ts | 3 + packages/live-preview-sdk/src/messages.ts | 1 + packages/live-preview-sdk/src/saveEvent.ts | 47 +++++++++++++ packages/live-preview-sdk/src/types.ts | 1 + packages/live-preview-sdk/src/utils.ts | 14 ++++ 9 files changed, 188 insertions(+), 23 deletions(-) create mode 100644 packages/live-preview-sdk/src/saveEvent.ts create mode 100644 packages/live-preview-sdk/src/utils.ts diff --git a/packages/live-preview-sdk/src/__tests__/index.spec.ts b/packages/live-preview-sdk/src/__tests__/index.spec.ts index f4c3f8d1..c922db7a 100644 --- a/packages/live-preview-sdk/src/__tests__/index.spec.ts +++ b/packages/live-preview-sdk/src/__tests__/index.spec.ts @@ -5,7 +5,7 @@ import { sendMessageToEditor, isInsideIframe } from '../helpers'; import { ContentfulLivePreview } from '../index'; import { InspectorMode } from '../inspectorMode'; import { LiveUpdates } from '../liveUpdates'; -import { TagAttributes } from '../types'; +import { SubscriptionEvent, TagAttributes } from '../types'; vi.mock('../inspectorMode'); vi.mock('../liveUpdates'); @@ -73,10 +73,14 @@ describe('ContentfulLivePreview', () => { // Check that the LiveUpdates.subscribe was called correctly expect(subscribe).toHaveBeenCalledOnce(); - expect(subscribe).toHaveBeenCalledWith({ data, locale: 'en-US', callback }); + expect(subscribe).toHaveBeenCalledWith(SubscriptionEvent.Edit, { + data, + locale: 'en-US', + callback, + }); // Updates from the subscribe fn will trigger the callback - subscribe.mock.lastCall?.[0].callback({ entity: { title: 'Hello' } }); + subscribe.mock.lastCall?.[1].callback({ entity: { title: 'Hello' } }); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith({ entity: { title: 'Hello' } }); diff --git a/packages/live-preview-sdk/src/__tests__/liveUpdates.spec.ts b/packages/live-preview-sdk/src/__tests__/liveUpdates.spec.ts index 0d5f32dc..18bacd7f 100644 --- a/packages/live-preview-sdk/src/__tests__/liveUpdates.spec.ts +++ b/packages/live-preview-sdk/src/__tests__/liveUpdates.spec.ts @@ -6,7 +6,7 @@ import { LIVE_PREVIEW_EDITOR_SOURCE } from '../constants'; import * as helpers from '../helpers'; import { LiveUpdates } from '../liveUpdates'; import { LivePreviewPostMessageMethods } from '../messages'; -import { ContentType } from '../types'; +import { ContentType, SubscriptionEvent } from '../types'; import assetFromEntryEditor from './fixtures/assetFromEntryEditor.json'; import landingPageContentType from './fixtures/landingPageContentType.json'; import nestedCollectionFromPreviewApp from './fixtures/nestedCollectionFromPreviewApp.json'; @@ -58,7 +58,7 @@ describe('LiveUpdates', () => { const liveUpdates = new LiveUpdates({ locale }); const data = { sys: { id: '1' }, title: 'Data 1', __typename: 'Demo' }; const callback = vi.fn(); - liveUpdates.subscribe({ data, callback }); + liveUpdates.subscribe(SubscriptionEvent.Edit, { data, callback }); await liveUpdates.receiveMessage({ entity: updateFromEntryEditor1, @@ -99,7 +99,7 @@ describe('LiveUpdates', () => { const data = { title: 'Data 1', __typename: 'Demo' }; const callback = vi.fn(); - liveUpdates.subscribe({ data, callback }); + liveUpdates.subscribe(SubscriptionEvent.Edit, { data, callback }); expect(helpers.debug.error).toHaveBeenCalledWith( 'Live Updates requires the "sys.id" to be present on the provided data', @@ -112,7 +112,7 @@ describe('LiveUpdates', () => { const liveUpdates = new LiveUpdates({ locale }); const data = { sys: { id: '1' }, title: 'Data 1', __typename: 'Demo' }; const callback = vi.fn(); - const unsubscribe = liveUpdates.subscribe({ data, callback }); + const unsubscribe = liveUpdates.subscribe(SubscriptionEvent.Edit, { data, callback }); await liveUpdates.receiveMessage({ entity: updateFromEntryEditor1, @@ -145,7 +145,7 @@ describe('LiveUpdates', () => { const liveUpdates = new LiveUpdates({ locale }); const data = { sys: { id: '1' }, title: 'Data 1', __typename: 'Demo' }; const callback = vi.fn(); - liveUpdates.subscribe({ data, callback }); + liveUpdates.subscribe(SubscriptionEvent.Edit, { data, callback }); await liveUpdates.receiveMessage({ isInspectorActive: false, @@ -162,7 +162,7 @@ describe('LiveUpdates', () => { const liveUpdates = new LiveUpdates({ locale }); const data = { sys: { id: '99' }, title: 'Data 1', __typename: 'Demo' }; const callback = vi.fn(); - liveUpdates.subscribe({ data, locale, callback }); + liveUpdates.subscribe(SubscriptionEvent.Edit, { data, locale, callback }); liveUpdates.receiveMessage({ entity: updateFromEntryEditor1, @@ -180,7 +180,7 @@ describe('LiveUpdates', () => { it('merges nested field updates', async () => { const liveUpdates = new LiveUpdates({ locale }); const callback = vi.fn(); - liveUpdates.subscribe({ data: nestedDataFromPreviewApp, callback }); + liveUpdates.subscribe(SubscriptionEvent.Edit, { data: nestedDataFromPreviewApp, callback }); await liveUpdates.receiveMessage({ entity: assetFromEntryEditor as unknown as Asset, action: LivePreviewPostMessageMethods.ENTRY_UPDATED, @@ -201,7 +201,10 @@ describe('LiveUpdates', () => { it('merges nested collections', async () => { const liveUpdates = new LiveUpdates({ locale }); const callback = vi.fn(); - liveUpdates.subscribe({ data: nestedCollectionFromPreviewApp, callback }); + liveUpdates.subscribe(SubscriptionEvent.Edit, { + data: nestedCollectionFromPreviewApp, + callback, + }); await liveUpdates.receiveMessage({ entity: pageInsideCollectionFromEntryEditor as unknown as Entry, contentType: landingPageContentType as unknown as ContentType, @@ -240,7 +243,7 @@ describe('LiveUpdates', () => { const liveUpdates = new LiveUpdates({ locale }); const data = { sys: { id: '1' }, title: 'Data 1', __typename: 'Demo' }; const callback = vi.fn(); - liveUpdates.subscribe({ data, callback }); + liveUpdates.subscribe(SubscriptionEvent.Edit, { data, callback }); expect(sendMessage).toHaveBeenCalledTimes(1); expect(sendMessage).toHaveBeenCalledWith(LivePreviewPostMessageMethods.SUBSCRIBED, { @@ -248,6 +251,7 @@ describe('LiveUpdates', () => { type: 'GQL', locale, entryId: '1', + event: 'edit', }); }); @@ -255,7 +259,7 @@ describe('LiveUpdates', () => { const liveUpdates = new LiveUpdates({ locale }); const data = { sys: { id: '1' }, fields: { title: 'Data 1' } }; const callback = vi.fn(); - liveUpdates.subscribe({ data, callback }); + liveUpdates.subscribe(SubscriptionEvent.Edit, { data, callback }); expect(sendMessage).toHaveBeenCalledTimes(1); expect(sendMessage).toHaveBeenCalledWith(LivePreviewPostMessageMethods.SUBSCRIBED, { @@ -263,6 +267,7 @@ describe('LiveUpdates', () => { type: 'REST', locale, entryId: '1', + event: 'edit', }); }); }); @@ -274,11 +279,13 @@ describe('LiveUpdates', () => { data, locale, callback: vi.fn(), + event: SubscriptionEvent.Edit, + sysId: id, }; const liveUpdates = new LiveUpdates({ locale }); beforeEach(() => { - liveUpdates.subscribe(subscription); + liveUpdates.subscribe(SubscriptionEvent.Edit, subscription); vi.clearAllMocks(); }); @@ -297,4 +304,41 @@ describe('LiveUpdates', () => { expect(subscription.callback).not.toHaveBeenCalled(); }); }); + + describe('save event subscription', () => { + it('should call only the save event subscriptions with the saved entry', async () => { + const liveUpdates = new LiveUpdates({ locale }); + + const data1 = { + sys: { id: updateFromEntryEditor1.sys.id }, + title: 'Title', + __typename: 'Foo', + }; + const callback1 = vi.fn(); + liveUpdates.subscribe(SubscriptionEvent.Save, { data: data1, callback: callback1 }); + + const data2 = { sys: { id: '2' }, title: 'Title', __typename: 'Foo' }; + const callback2 = vi.fn(); + liveUpdates.subscribe(SubscriptionEvent.Save, { data: data2, callback: callback2 }); + + const data3 = { sys: { id: '3' }, title: 'Title', __typename: 'Foo' }; + const callback3 = vi.fn(); + liveUpdates.subscribe(SubscriptionEvent.Save, { data: data3, callback: callback3 }); + + await liveUpdates.receiveMessage({ + entity: updateFromEntryEditor1, + contentType, + from: 'live-preview', + method: LivePreviewPostMessageMethods.ENTRY_SAVED, + source: LIVE_PREVIEW_EDITOR_SOURCE, + entityReferenceMap: new Map(), + }); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledWith(updateFromEntryEditor1); + + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/live-preview-sdk/src/helpers/validation.ts b/packages/live-preview-sdk/src/helpers/validation.ts index 19f19d00..b288fd97 100644 --- a/packages/live-preview-sdk/src/helpers/validation.ts +++ b/packages/live-preview-sdk/src/helpers/validation.ts @@ -26,32 +26,51 @@ function validation(d: Argument): { isGQL: boolean; sysId: string | null; isREST } } +type ValidationResult = ( + | { + isValid: true; + sysId: string; + } + | { + isValid: false; + sysId: string | null; + } +) & { isGQL: boolean; isREST: boolean }; + /** * **Basic** validating of the subscribed data * Is it GraphQL or REST and does it contain the sys information */ -export function validateDataForLiveUpdates(data: Argument) { - let isValid = true; - +export function validateDataForLiveUpdates(data: Argument): ValidationResult { const { isGQL, sysId, isREST } = validation(data); if (!sysId) { - isValid = false; debug.error('Live Updates requires the "sys.id" to be present on the provided data', data); + return { + isValid: false, + sysId, + isGQL, + isREST, + }; } if (!isGQL && !isREST) { - isValid = false; debug.error( 'For live updates as a basic requirement the provided data must include the "fields" property for REST or "__typename" for Graphql, otherwise the data will be treated as invalid and live updates are not applied.', data ); + return { + isValid: false, + sysId, + isGQL, + isREST, + }; } return { isGQL, isREST, sysId, - isValid, + isValid: true, }; } diff --git a/packages/live-preview-sdk/src/index.ts b/packages/live-preview-sdk/src/index.ts index 6b35f87d..96f37539 100644 --- a/packages/live-preview-sdk/src/index.ts +++ b/packages/live-preview-sdk/src/index.ts @@ -26,6 +26,8 @@ import { SubscribeCallback, TagAttributes, } from './types'; +import { getEntryList } from './utils'; +import { SaveEvent } from './saveEvent'; export interface ContentfulLivePreviewInitConfig { locale: string; @@ -45,6 +47,7 @@ export class ContentfulLivePreview { static initialized = false; static inspectorMode: InspectorMode | null = null; static liveUpdates: LiveUpdates | null = null; + static saveEvent: SaveEvent | null = null; static inspectorModeEnabled = true; static liveUpdatesEnabled = true; static locale: string; @@ -96,6 +99,7 @@ export class ContentfulLivePreview { if (this.liveUpdatesEnabled) { ContentfulLivePreview.liveUpdates = new LiveUpdates({ locale }); + ContentfulLivePreview.saveEvent = new SaveEvent({ locale }); } // bind event listeners for interactivity @@ -150,20 +154,41 @@ export class ContentfulLivePreview { } } - static subscribe(config: ContentfulSubscribeConfig): VoidFunction { + static subscribe(config: ContentfulSubscribeConfig): VoidFunction; + static subscribe( + event: 'save', + config: Pick + ): VoidFunction; + static subscribe(event: 'edit', config: ContentfulSubscribeConfig): VoidFunction; + static subscribe( + configOrEvent: 'save' | 'edit' | ContentfulSubscribeConfig, + config?: ContentfulSubscribeConfig | Pick + ): VoidFunction { if (!this.liveUpdatesEnabled) { return () => { /* noop */ }; } + const event = typeof configOrEvent === 'string' ? configOrEvent : 'edit'; + const subscribeConfig = typeof configOrEvent === 'object' ? configOrEvent : config!; + + if (event === 'save') { + if (!this.saveEvent) { + throw new Error( + 'Save event is not initialized, please call `ContentfulLivePreview.init()` first.' + ); + } + return this.saveEvent.subscribe(subscribeConfig.callback); + } + if (!this.liveUpdates) { throw new Error( - 'Live Updates are not initialized, please call `ContentfulLivePreview.init()` first.' + 'Live updates are not initialized, please call `ContentfulLivePreview.init()` first.' ); } - return this.liveUpdates.subscribe(config); + return this.liveUpdates.subscribe(subscribeConfig as ContentfulSubscribeConfig); } // Static method to render live preview data-attributes to HTML element output @@ -201,6 +226,13 @@ export class ContentfulLivePreview { openEntryInEditorUtility(fieldId, entryId, locale || this.locale); } + + /** + * Returns a list of tagged entries on the page + */ + static getEntryList(): string[] { + return getEntryList(); + } } export { LIVE_PREVIEW_EDITOR_SOURCE, LIVE_PREVIEW_SDK_SOURCE } from './constants'; diff --git a/packages/live-preview-sdk/src/liveUpdates.ts b/packages/live-preview-sdk/src/liveUpdates.ts index 152c9722..b1e6c395 100644 --- a/packages/live-preview-sdk/src/liveUpdates.ts +++ b/packages/live-preview-sdk/src/liveUpdates.ts @@ -2,6 +2,7 @@ import type { Asset, Entry } from 'contentful'; import type { ContentfulSubscribeConfig, + EntrySavedMessage, EntryUpdatedMessage, ErrorMessage, MessageFromEditor, @@ -296,6 +297,7 @@ export class LiveUpdates { this.subscriptions.set(id, { ...config, + sysId, gqlParams: config.query ? parseGraphQLParams(config.query) : undefined, }); @@ -314,6 +316,7 @@ export class LiveUpdates { type: isGQL ? 'GQL' : 'REST', locale, entryId: sysId, + event: 'edit', } as SubscribedMessage); return () => { diff --git a/packages/live-preview-sdk/src/messages.ts b/packages/live-preview-sdk/src/messages.ts index 68c06220..4a5dce36 100644 --- a/packages/live-preview-sdk/src/messages.ts +++ b/packages/live-preview-sdk/src/messages.ts @@ -82,6 +82,7 @@ export type SubscribedMessage = { type: 'GQL' | 'REST'; entryId: string; locale: string; + event: 'edit' | 'save'; }; export type ErrorMessage = { diff --git a/packages/live-preview-sdk/src/saveEvent.ts b/packages/live-preview-sdk/src/saveEvent.ts new file mode 100644 index 00000000..5c2f61c9 --- /dev/null +++ b/packages/live-preview-sdk/src/saveEvent.ts @@ -0,0 +1,47 @@ +import { debug } from './helpers'; +import { EntrySavedMessage, LivePreviewPostMessageMethods, MessageFromEditor } from './messages'; +import { SubscribeCallback } from './types'; +import { getEntryList } from './utils'; + +export class SaveEvent { + locale: string; + subscription: SubscribeCallback | undefined; + + constructor({ locale }: { locale: string }) { + this.locale = locale; + } + + public subscribe(cb: SubscribeCallback): VoidFunction { + if (this.subscription) { + debug.log( + 'There is already a subscription for the save event, the existing will be replaced.' + ); + } + + this.subscription = cb; + + // TODO: How would we like to handle this for the subscribe event? + // sendMessageToEditor(LivePreviewPostMessageMethods.SUBSCRIBED, { + // action: LivePreviewPostMessageMethods.SUBSCRIBED, + // type: isGQL ? 'GQL' : 'REST', // we don't know that + // locale, + // entryId: sysId, // we don't have that + // event: 'save', + // } as SubscribedMessage); + + return () => { + this.subscription = undefined; + }; + } + + public receiveMessage(message: MessageFromEditor): void { + if (message.method === LivePreviewPostMessageMethods.ENTRY_SAVED && this.subscription) { + const { entity } = message as EntrySavedMessage; + const entries = getEntryList(); + + if (entries.includes(entity.sys.id)) { + this.subscription(entity); + } + } + } +} diff --git a/packages/live-preview-sdk/src/types.ts b/packages/live-preview-sdk/src/types.ts index e05e8dd4..95b5de50 100644 --- a/packages/live-preview-sdk/src/types.ts +++ b/packages/live-preview-sdk/src/types.ts @@ -103,4 +103,5 @@ export interface Subscription { locale?: string; callback: SubscribeCallback; gqlParams?: GraphQLParams; + sysId: string; } diff --git a/packages/live-preview-sdk/src/utils.ts b/packages/live-preview-sdk/src/utils.ts new file mode 100644 index 00000000..61ee4d13 --- /dev/null +++ b/packages/live-preview-sdk/src/utils.ts @@ -0,0 +1,14 @@ +import { TagAttributes } from './types'; + +/** + * Returns a list of tagged entries on the page + */ +export function getEntryList(): string[] { + return [ + ...new Set( + [...document.querySelectorAll(TagAttributes.ENTRY_ID)] + .map((element) => element.getAttribute(TagAttributes.ENTRY_ID)) + .filter(Boolean) as string[] + ), + ]; +}