From e846cf09f5e4ca8eb5daf3f9c651b1d0387b0839 Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Thu, 6 Jun 2024 19:55:15 +0200 Subject: [PATCH] Implement liveReload utils to simplify live reload implementations --- README.md | 40 +- src/index.ts | 1 + src/utils/liveReload.ts | 384 ++++++++++++++++++ src/utils/liveReload/optionallyAsync.ts | 66 +++ test-browser/utils/liveReload.spec.ts | 513 ++++++++++++++++++++++++ 5 files changed, 984 insertions(+), 20 deletions(-) create mode 100644 src/utils/liveReload.ts create mode 100644 src/utils/liveReload/optionallyAsync.ts create mode 100644 test-browser/utils/liveReload.spec.ts diff --git a/README.md b/README.md index 838e8fb..7a5ced0 100644 --- a/README.md +++ b/README.md @@ -461,17 +461,24 @@ application. #### Implementing live preview in your application To set up live preview, listen for update events from the SDK. These events are triggered after content is -edited in Kontent.ai, providing you with the updated data: +edited in Kontent.ai, providing you with the updated data. +In a typical application, you would fetch the data from the Delivery API and store them in memory. +When the SDK triggers an update event, you would then update the stored items in memory to display the latest content. +To easily apply the updates on you items, you can use `applyUpdateOnItem` or `applyUpdateOnItemAndLoadLinkedItems` functions from the SDK. ```ts -import KontentSmartLink, { KontentSmartLinkEvent } from '@kontent-ai/smart-link'; +import KontentSmartLink, { KontentSmartLinkEvent, applyUpdateOnItem, applyUpdateOnItemAndLoadLinkedItems } from '@kontent-ai/smart-link'; // Initialize the SDK const sdk = KontentSmartLink.initialize({ ... }); // Listen for updates and apply them to your application sdk.on(KontentSmartLink.Update, (data: IUpdateMessageData) => { - // Use this data to update your application state or UI as needed + // Use this data to update your application state or UI as needed e.g.: + setItems((items) => items.map(item => applyUpdateOnItem(item, data))); + // or + Promise.all(items.map(item => applyUpdateOnItemAndLoadLinkedItems(item, data, fetchItemsFromDeliveryApi))) + .then(setItems); }); ``` @@ -513,6 +520,8 @@ and [Element](https://github.com/kontent-ai/delivery-sdk-js/blob/v14.6.0/lib/ele Live preview updates for content items that include linked items only provide the codenames of these linked items. To fully update your application with changes to these linked items, you may need to fetch their full details from the Delivery Preview API after receiving the live update message. This ensures that all parts of your content are up-to-date. +You can use the `applyUpdateOnItemAndLoadLinkedItems` function to simplify this process. +The function uses the provided loader to load any items added in the update message and applies the update to the item. Content components within rich text elements, however, are directly included in the live update messages. This means changes to these components are immediately reflected in the live preview, without needing additional fetches. @@ -924,15 +933,6 @@ import { useLivePreview } from '../contexts/SmartLinkContext'; // Adjust the imp import { IContentItem } from '@kontent-ai/delivery-sdk/lib/models/item-models'; import { IUpdateMessageData } from '@kontent-ai/smart-link/types/lib/IFrameCommunicatorTypes'; -// Function to update content item elements with live preview data -const updateContentItemElements = (item: IContentItem, data: IUpdateMessageData) => { - return data.elements.reduce((acc, el) => { - const { element, ...rest } = el; - acc[element.codename] = { ...acc[element.codename], ...rest.data }; - return acc; - }, item?.elements ?? {}); -}; - const useContentItem = (codename: string) => { const [item, setItem] = useState(null); // Assume useDeliveryClient is a custom hook to obtain a configured delivery client instance @@ -940,18 +940,18 @@ const useContentItem = (codename: string) => { const handleLiveUpdate = useCallback((data: IUpdateMessageData) => { if (item && data.item.codename === codename) { - const updatedElements = updateContentItemElements(item, data); - setItem({ ...item, elements: updatedElements }); + setItem(applyUpdateOnItem(item, data)); + // or use applyUpdateOnItemAndLoadLinkedItems to load added linked items + applyUpdateOnItemAndLoadLinkedItems(item, data, codenamesToFetch => deliveryClient.items(codenamesToFetch).toAllPromise()) + .then(setItem); } }, [codename, item]); useEffect(() => { - const fetchItem = async () => { - // Fetch the content item initially and upon codename changes - const response = await deliveryClient.item(codename).toPromise(); - setItem(response.item); - }; - fetchItem(); + // Fetch the content item initially and upon codename changes + deliveryClient.item(codename) + .toPromise() + .then(res => setItem(res.item)); }, [codename, deliveryClient]); useLivePreview(handleLiveUpdate); diff --git a/src/index.ts b/src/index.ts index 1bdedc3..62a8fb5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export { default, KontentSmartLinkEvent } from './sdk'; export { buildKontentLink, buildElementLink, buildComponentElementLink } from './utils/link'; +export { applyUpdateOnItemAndLoadLinkedItems, applyUpdateOnItem } from './utils/liveReload'; export * from './models'; diff --git a/src/utils/liveReload.ts b/src/utils/liveReload.ts new file mode 100644 index 0000000..8884e3c --- /dev/null +++ b/src/utils/liveReload.ts @@ -0,0 +1,384 @@ +import { + camelCasePropertyNameResolver, + ElementModels, + Elements, + ElementType, + IContentItem, + IContentItemElements, +} from '@kontent-ai/delivery-sdk'; +import { + CustomElementUpdateData, + DatetimeElementUpdateData, + ElementUpdateData, + LinkedItemsElementUpdateData, + RichTextElementUpdateData, +} from '../models/ElementUpdateData'; +import { IUpdateMessageData } from '../lib/IFrameCommunicatorTypes'; +import { + applyOnOptionallyAsync, + evaluateOptionallyAsync, + createOptionallyAsync, + mergeOptionalAsyncs, + chainOptionallyAsync, + OptionallyAsync, +} from './liveReload/optionallyAsync'; + +export const applyUpdateOnItem = ( + item: IContentItem, + update: IUpdateMessageData, + resolveElementCodename?: (codename: string) => string +): IContentItem => + evaluateOptionallyAsync(applyUpdateOnItemOptionallyAsync(item, update, resolveElementCodename || null), null); + +export const applyUpdateOnItemAndLoadLinkedItems = ( + item: IContentItem, + update: IUpdateMessageData, + fetchItems: (itemCodenames: ReadonlyArray) => Promise>, + resolveElementCodename?: (codename: string) => string +): Promise> => + evaluateOptionallyAsync(applyUpdateOnItemOptionallyAsync(item, update, resolveElementCodename || null), fetchItems); + +const applyUpdateOnItemOptionallyAsync = ( + item: IContentItem, + update: InternalUpdateMessage, + resolveElementCodename: null | ((codename: string) => string) +): OptionallyAsync> => { + const shouldApplyOnThisItem = + item.system.codename === update.item.codename && item.system.language === update.variant.codename; + + const resolveCodename = resolveElementCodename ?? ((c: string) => camelCasePropertyNameResolver('', c)); + + const elementUpdates = update.elements.map((u) => ({ + ...u, + element: { + ...u.element, + codename: resolveCodename(u.element.codename), + }, + })); + + const updatedElements = mergeOptionalAsyncs( + Object.entries(item.elements).map(([elementCodename, element]) => { + const matchingUpdate = elementUpdates.find((u) => u.element.codename === elementCodename); + + if (shouldApplyOnThisItem && matchingUpdate) { + return applyOnOptionallyAsync( + applyUpdateOnElement(element, matchingUpdate, resolveCodename), + (newElement) => [elementCodename, newElement] as const + ); + } + + if (element.type === ElementType.ModularContent || element.type === ElementType.RichText) { + const typedItemElement = element as Elements.LinkedItemsElement | Elements.RichTextElement; + + return applyOnOptionallyAsync( + mergeOptionalAsyncs( + typedItemElement.linkedItems.map((i) => applyUpdateOnItemOptionallyAsync(i, update, resolveElementCodename)) + ), + (linkedItems) => { + return linkedItems.some((newItem, index) => newItem !== typedItemElement.linkedItems[index]) + ? ([elementCodename, { ...typedItemElement, linkedItems }] as const) + : ([elementCodename, typedItemElement] as const); + } + ); + } + + return createOptionallyAsync(() => [elementCodename, element] as const); + }) + ); + + return applyOnOptionallyAsync(updatedElements, (newElements) => + newElements.some(([codename, newEl]) => item.elements[codename] !== newEl) + ? { ...item, elements: Object.fromEntries(newElements) as Elements } + : item + ); +}; + +const applyUpdateOnElement = ( + element: ElementModels.IElement, + update: InternalUpdateElementMessage, + resolveCodenames: (codename: string) => string +): OptionallyAsync> => { + switch (update.type) { + case ElementType.Text: + case ElementType.Number: + case ElementType.UrlSlug: + return createOptionallyAsync(() => + applySimpleElement(element as Elements.TextElement | Elements.NumberElement | Elements.UrlSlugElement, update) + ); + case ElementType.ModularContent: + return applyLinkedItemsElement(element as Elements.LinkedItemsElement, update); + case ElementType.RichText: + return applyRichTextElement(element as Elements.RichTextElement, update, resolveCodenames); + case ElementType.MultipleChoice: + return createOptionallyAsync(() => + applyArrayElement(element as Elements.MultipleChoiceElement, update, (o1, o2) => o1?.codename === o2?.codename) + ); + case ElementType.DateTime: + return createOptionallyAsync(() => applyDateTimeElement(element as Elements.DateTimeElement, update)); + case ElementType.Asset: + return createOptionallyAsync(() => + applyArrayElement(element as Elements.AssetsElement, update, (a1, a2) => a1?.url === a2?.url) + ); + case ElementType.Taxonomy: + return createOptionallyAsync(() => + applyArrayElement(element as Elements.TaxonomyElement, update, (t1, t2) => t1?.codename === t2?.codename) + ); + case ElementType.Custom: + return createOptionallyAsync(() => applyCustomElement(element as Elements.CustomElement, update)); + default: + return createOptionallyAsync(() => element); + } +}; + +type ElementUpdate> = Readonly<{ + data: Readonly<{ value: El['value'] }>; +}>; + +const applyCustomElement = ( + element: Elements.CustomElement, + update: CustomElementUpdateData +): Elements.CustomElement => + typeof element.value === 'string' && element.value !== update.data.value + ? { ...element, value: update.data.value } + : element; + +const applyDateTimeElement = ( + element: Elements.DateTimeElement, + update: DatetimeElementUpdateData +): Elements.DateTimeElement => + element.value === update.data.value && element.displayTimeZone === update.data.displayTimeZone + ? element + : { ...element, value: update.data.value, displayTimeZone: update.data.displayTimeZone }; + +const applySimpleElement = >( + element: Element, + update: ElementUpdate +): Element => (element.value === update.data.value ? element : { ...element, value: update.data.value }); + +const applyArrayElement = }>>( + element: Element, + update: ElementUpdate, + areSame: (el1: ArrayElement | undefined, el2: ArrayElement | undefined) => boolean +): Element => + element.value.length === update.data.value.length && element.value.every((el, i) => areSame(el, update.data.value[i])) + ? element + : { ...element, value: update.data.value }; + +const applyLinkedItemsElement = ( + element: Elements.LinkedItemsElement, + update: LinkedItemsElementUpdateData +): OptionallyAsync => { + if (areLinkedItemsSame(element.value, update.data.value)) { + return createOptionallyAsync(() => element); + } + + return applyOnOptionallyAsync(updateLinkedItems(update.data.value, element.linkedItems), (linkedItems) => ({ + ...element, + value: update.data.value, + linkedItems, + })); +}; + +const applyRichTextElement = ( + element: Elements.RichTextElement, + update: RichTextElementUpdateData, + resolveCodenames: (codename: string) => string +): OptionallyAsync => { + if (areRichTextElementsSame(element, update.data)) { + return createOptionallyAsync(() => element); + } + + const withItems = applyOnOptionallyAsync( + updateLinkedItems(update.data.linkedItemCodenames, element.linkedItems), + (linkedItems) => ({ + ...element, + value: update.data.value, + linkedItemCodenames: update.data.linkedItemCodenames, + links: update.data.links, + images: update.data.images, + linkedItems, + }) + ); + + return chainOptionallyAsync(withItems, (el) => + applyOnOptionallyAsync( + updateComponents(update.data.linkedItems, el.linkedItems, resolveCodenames), + (linkedItems) => ({ ...el, linkedItems }) + ) + ); +}; + +const areItemsSame = (item1: IContentItem, item2: IContentItem): boolean => + item1.system.codename === item2.system.codename && + item1.system.language === item2.system.language && + Object.entries(item1.elements).every(([codename, el1]) => areElementsSame(el1, item2.elements[codename])); + +const areElementsSame = (el1: ElementModels.IElement, el2: ElementModels.IElement): boolean => { + switch (el1.type) { + case ElementType.Text: + case ElementType.Number: + case ElementType.UrlSlug: + return el1.value === el2.value; + case ElementType.MultipleChoice: { + const typedElement1 = el1 as Elements.MultipleChoiceElement; + const typedElement2 = el2 as Elements.MultipleChoiceElement; + return ( + typedElement1.value.length === typedElement2.value.length && + typedElement1.value.every((option, i) => option.codename === el2.value[i].codename) + ); + } + case ElementType.DateTime: { + const typedElement1 = el1 as Elements.DateTimeElement; + const typedElement2 = el2 as Elements.DateTimeElement; + return ( + typedElement1.value === typedElement2.value && typedElement1.displayTimeZone === typedElement2.displayTimeZone + ); + } + case ElementType.RichText: { + const typedElement1 = el1 as Elements.RichTextElement; + const typedElement2 = el2 as Elements.RichTextElement; + return areRichTextElementsSame(typedElement1, typedElement2); + } + case ElementType.Taxonomy: { + const typedElement1 = el1 as Elements.TaxonomyElement; + const typedElement2 = el2 as Elements.TaxonomyElement; + return ( + typedElement1.value.length === typedElement2.value.length && + typedElement1.value.every((term, i) => term.codename === typedElement2.value[i].codename) + ); + } + case ElementType.Asset: { + const typedElement1 = el1 as Elements.AssetsElement; + const typedElement2 = el2 as Elements.AssetsElement; + return ( + typedElement1.value.length === typedElement2.value.length && + typedElement1.value.every((asset, i) => asset.url === typedElement2.value[i].url) + ); + } + case ElementType.ModularContent: { + const typedElement1 = el1 as Elements.LinkedItemsElement; + const typedElement2 = el2 as Elements.LinkedItemsElement; + return ( + typedElement1.value.length === typedElement2.value.length && + typedElement1.value.every((item, i) => item === typedElement2.value[i]) + ); + } + case ElementType.Custom: + return el1.value === el2.value; + default: + throw new Error(); + } +}; + +const areRichTextElementsSame = ( + el1: Omit, + el2: Omit +): boolean => + el1.value === el2.value && + el1.links.length === el2.links.length && + el1.links.every((link, i) => link.codename === el2.links[i].codename) && + el1.images.length === el2.images.length && + el1.images.every((image, i) => image.url === el2.images[i].url) && + el1.linkedItemCodenames.length === el2.linkedItemCodenames.length && + el1.linkedItemCodenames.every((codename, i) => codename === el2.linkedItemCodenames[i]) && + el1.linkedItems.length === el2.linkedItems.length && + el1.linkedItems.every((item, i) => areItemsSame(item, el2.linkedItems[i])); + +const updateComponents = ( + newItems: ReadonlyArray, + oldItems: ReadonlyArray, + resolveCodenames: (codename: string) => string +) => + mergeOptionalAsyncs( + oldItems.map((item) => { + const newItem = newItems.find((i) => i.system.codename === item.system.codename); + if (!newItem) { + return createOptionallyAsync(() => item); + } + + return applyUpdateOnItemOptionallyAsync(item, convertItemToUpdate(newItem), resolveCodenames); + }) + ); + +const updateLinkedItems = (newValue: ReadonlyArray, loadedItems: ReadonlyArray) => { + const itemsByCodename = new Map(loadedItems.map((i) => [i.system.codename, i])); + const newLinkedItems = newValue.map((codename) => itemsByCodename.get(codename) ?? codename); + const itemsToFetch = newLinkedItems.filter(isString); + + return applyOnOptionallyAsync( + createOptionallyAsync((fetchItems) => (fetchItems && itemsToFetch.length ? fetchItems(itemsToFetch) : [])), + (fetchedItemsArray) => { + const fetchedItems = new Map(fetchedItemsArray.map((i) => [i.system.codename, i] as const)); + + return newLinkedItems + .map((codename) => (isString(codename) ? fetchedItems.get(codename) ?? null : codename)) + .filter(notNull); + } + ); +}; + +const areLinkedItemsSame = (items1: ReadonlyArray, items2: ReadonlyArray) => + items1.length === items2.length && items1.every((codename, index) => codename === items2[index]); + +const notNull = (value: T | null): value is T => value !== null; +const isString = (value: unknown): value is string => typeof value === 'string'; + +// Simplified IUpdateMessageData to make it possible converting IContentItem into it +type InternalUpdateMessage = Readonly<{ + variant: Readonly<{ codename: string }>; + item: Readonly<{ codename: string }>; + elements: ReadonlyArray; +}>; + +type InternalUpdateElementMessage = Readonly<{ element: Readonly<{ codename: string }> }> & ElementUpdateData; + +const convertItemToUpdate = (item: IContentItem): InternalUpdateMessage => ({ + variant: { codename: item.system.language }, + item: { codename: item.system.codename }, + elements: Object.entries(item.elements).map(([elCodename, el]) => { + switch (el.type) { + case ElementType.Number: + case ElementType.UrlSlug: + case ElementType.MultipleChoice: + case ElementType.Custom: + case ElementType.Asset: + case ElementType.Text: { + return { + element: { codename: elCodename }, + type: el.type, + data: el, + }; + } + case ElementType.DateTime: { + return { + element: { codename: elCodename }, + type: el.type, + data: el as Elements.DateTimeElement, + }; + } + case ElementType.RichText: { + return { + element: { codename: elCodename }, + type: el.type, + data: el as Elements.RichTextElement, + }; + } + case ElementType.Taxonomy: { + return { + element: { codename: elCodename }, + type: el.type, + data: el as Elements.TaxonomyElement, + }; + } + case ElementType.ModularContent: { + return { + element: { codename: elCodename }, + type: el.type, + data: el as Elements.LinkedItemsElement, + }; + } + default: + throw new Error(); + } + }), +}); diff --git a/src/utils/liveReload/optionallyAsync.ts b/src/utils/liveReload/optionallyAsync.ts new file mode 100644 index 0000000..552e5e3 --- /dev/null +++ b/src/utils/liveReload/optionallyAsync.ts @@ -0,0 +1,66 @@ +import { IContentItem } from '@kontent-ai/delivery-sdk'; + +export type OptionallyAsync = Readonly<{ + optionallyAsyncFnc: ( + fetchItems?: (codenames: ReadonlyArray) => Promise> + ) => T | Promise; +}>; + +export const createOptionallyAsync = ( + fnc: (fetchItems?: (codenames: ReadonlyArray) => Promise>) => T | Promise +): OptionallyAsync => ({ + optionallyAsyncFnc: fnc, +}); + +export const applyOnOptionallyAsync = ( + fnc: OptionallyAsync, + transformer: (input: Input) => Output +): OptionallyAsync => ({ + optionallyAsyncFnc: (fetchItems) => { + const input = fnc.optionallyAsyncFnc(fetchItems); + + return input instanceof Promise ? input.then(transformer) : transformer(input); + }, +}); + +export const chainOptionallyAsync = ( + fnc: OptionallyAsync, + chainCall: (input: Input) => OptionallyAsync +): OptionallyAsync => flattenOptionallyAsync(applyOnOptionallyAsync(fnc, chainCall)); + +const flattenOptionallyAsync = (nested: OptionallyAsync>): OptionallyAsync => ({ + optionallyAsyncFnc: (fetchItems) => { + const outerResult = nested.optionallyAsyncFnc(fetchItems); + + const innerResult = + outerResult instanceof Promise + ? outerResult.then((res) => res.optionallyAsyncFnc(fetchItems)) + : outerResult.optionallyAsyncFnc(fetchItems); + + return innerResult; + }, +}); + +export const mergeOptionalAsyncs = (asyncs: ReadonlyArray>): OptionallyAsync => ({ + optionallyAsyncFnc: (fetchItems) => { + const evaluated = asyncs.map((as) => as.optionallyAsyncFnc(fetchItems)); + + const nonPromises = evaluated.filter((e: T | Promise): e is T => !(e instanceof Promise)); + + return nonPromises.length === evaluated.length + ? nonPromises + : Promise.all(evaluated.map((e) => (e instanceof Promise ? e : Promise.resolve(e)))); + }, +}); + +export function evaluateOptionallyAsync( + fnc: OptionallyAsync, + fetchItems: (codenames: ReadonlyArray) => Promise> +): Promise; +export function evaluateOptionallyAsync(fnc: OptionallyAsync, fetchItems: null): T; +export function evaluateOptionallyAsync( + fnc: OptionallyAsync, + fetchItems: null | ((codenames: ReadonlyArray) => Promise>) +): T | Promise { + return fnc.optionallyAsyncFnc(fetchItems ?? undefined); +} diff --git a/test-browser/utils/liveReload.spec.ts b/test-browser/utils/liveReload.spec.ts new file mode 100644 index 0000000..7d9620a --- /dev/null +++ b/test-browser/utils/liveReload.spec.ts @@ -0,0 +1,513 @@ +import { Elements, ElementType, IContentItem, IContentItemElements } from '@kontent-ai/delivery-sdk'; +import { IUpdateMessageData } from '../../src/lib/IFrameCommunicatorTypes'; +import { applyUpdateOnItem, applyUpdateOnItemAndLoadLinkedItems } from '../../src/utils/liveReload'; + +const system: IContentItem['system'] = { + id: '70105014-c767-45b6-9393-31bef0952bce', + name: 'item', + codename: 'item', + type: 'itemType', + language: 'itemLanguage', + workflow: 'itemWf', + collection: 'itemColl', + lastModified: 'lastModified', + workflowStep: 'itemWfStep', + sitemapLocations: [], +}; + +[true, false].forEach((isAsync) => { + describe(isAsync ? 'applyUpdateOnItemAndLoadLinkedItems' : 'applyUpdateOnItem', () => { + const callTestFnc = async ( + item: IContentItem, + update: IUpdateMessageData, + resolveCodename?: (codename: string) => string + ): Promise> => + isAsync + ? await applyUpdateOnItemAndLoadLinkedItems(item, update, () => Promise.resolve([]), resolveCodename) + : applyUpdateOnItem(item, update, resolveCodename); + + it('applies update to all elements', async () => { + const item: IContentItem = { + system, + elements: { + number: { + type: ElementType.Number, + name: 'number', + value: 42, + }, + text: { + type: ElementType.Text, + name: 'text', + value: 'original value', + }, + slug: { + type: ElementType.UrlSlug, + name: 'slug', + value: 'original slug', + }, + date: { + type: ElementType.DateTime, + name: 'dateTime', + value: new Date(1316, 4, 14).toISOString(), + displayTimeZone: 'Europe/Prague', + } as Elements.DateTimeElement, + custom: { + type: ElementType.Custom, + name: 'custom', + value: 'original custom value', + }, + }, + }; + const update: IUpdateMessageData = { + item: { id: system.id, codename: system.codename }, + variant: { id: '0bd842f8-e6dd-4c0a-b677-5d850152f452', codename: system.language }, + projectId: '036f5efc-208a-4967-9ec8-b7e25fd0c18b', + elements: [ + { + type: ElementType.Number, + element: { + id: '9e5b76c1-8759-48be-a80c-10fdf3074bd9', + codename: 'number', + }, + data: { + value: 69, + }, + }, + { + type: ElementType.Text, + element: { + id: '', + codename: 'text', + }, + data: { + value: 'new value', + }, + }, + { + type: ElementType.UrlSlug, + element: { + id: '', + codename: 'slug', + }, + data: { + value: 'new slug', + }, + }, + { + type: ElementType.DateTime, + element: { + id: '', + codename: 'date', + }, + data: { + value: new Date(1378, 10, 29).toISOString(), + displayTimeZone: 'Europe/Oslo', + }, + }, + { + type: ElementType.Custom, + element: { id: '', codename: 'custom' }, + data: { value: 'new custom value' }, + }, + ], + }; + + const result = await callTestFnc(item, update); + + expect(result).toEqual({ + ...item, + elements: { + ...item.elements, + number: { ...item.elements['number'], value: 69 }, + text: { ...item.elements['text'], value: 'new value' }, + slug: { ...item.elements['slug'], value: 'new slug' }, + date: { + ...item.elements['date'], + value: new Date(1378, 10, 29).toISOString(), + displayTimeZone: 'Europe/Oslo', + } as Elements.DateTimeElement, + custom: { ...item.elements['custom'], value: 'new custom value' }, + }, + }); + }); + + it('Returns the same item (the same object) when the update is not related to it', async () => { + const item: IContentItem<{ num: Elements.NumberElement }> = { + system, + elements: { + num: { + type: ElementType.Number, + name: 'number element', + value: 42, + }, + }, + }; + + const update: IUpdateMessageData = { + item: { id: 'dbd3001b-7fad-4ef4-90a5-e12159e19dc8', codename: 'some_other_item' }, + variant: { id: 'edbb01d8-8cf1-44c2-9b8d-0811f287f7ac', codename: item.system.language }, + projectId: '21817139-6c9c-484f-95fa-52e4fe08c2a4', + elements: [ + { + type: ElementType.Number, + element: { id: '4779792a-593b-4e85-b181-bb21b4e72652', codename: 'num' }, + data: { value: 69 }, + }, + ], + }; + + const result: IContentItem<{ num: Elements.NumberElement }> = await callTestFnc(item, update); + + expect(result).toBe(item); + }); + + it('Applies the update to loaded linked items', async () => { + const innerItem: IContentItem = { + system, + elements: { + num: { + type: ElementType.Number, + name: 'number element', + value: 42, + }, + }, + }; + const item: IContentItem = { + system: { ...system, id: 'c0ee19ca-8285-43ec-ab40-5bc529d81439', codename: 'parent_item' }, + elements: { + linked: { + type: ElementType.ModularContent, + name: 'linked items element', + value: [system.codename], + linkedItems: [innerItem], + } as Elements.LinkedItemsElement, + rich: { + type: ElementType.RichText, + name: 'rich text element', + value: ``, + links: [], + images: [], + linkedItemCodenames: [system.codename], + linkedItems: [innerItem], + } as Elements.RichTextElement, + }, + }; + const update: IUpdateMessageData = { + item: { id: system.id, codename: system.codename }, + variant: { id: 'b38fa222-697f-433c-ae4d-39ac1d6c26d1', codename: system.language }, + projectId: '65814f5e-f346-4adb-836f-10229729ab92', + elements: [ + { + type: ElementType.Number, + element: { id: '7f5ef676-4dbe-4346-b2d2-6cf401f56662', codename: 'num' }, + data: { value: 69 }, + }, + ], + }; + const updatedInnerItem: IContentItem = { + ...innerItem, + elements: { + num: { ...innerItem.elements.num, value: 69 }, + }, + }; + + const result = await callTestFnc(item, update); + + expect(result).toEqual({ + ...item, + elements: { + linked: { ...item.elements.linked, linkedItems: [updatedInnerItem] } as Elements.LinkedItemsElement, + rich: { ...item.elements.rich, linkedItems: [updatedInnerItem] } as Elements.RichTextElement, + }, + }); + }); + + it('Uses provided codename resolver to transform element codenames before applying them on the item', async () => { + const item: IContentItem = { + system, + elements: { + testElementResolved: { + type: ElementType.Number, + name: 'number element', + value: 42, + }, + }, + }; + const update: IUpdateMessageData = { + item: { id: item.system.id, codename: item.system.codename }, + variant: { id: '87767c98-3d1d-490f-bd19-e0157157d087', codename: item.system.language }, + projectId: '5f53475c-de51-4cef-b373-463d56919cec', + elements: [ + { + type: ElementType.Number, + element: { id: '467dc8c1-4fcb-4adc-a5fc-049d17ee1386', codename: 'test_element' }, + data: { value: 69 }, + }, + ], + }; + + const result = await callTestFnc(item, update, (c) => (c === 'test_element' ? 'testElementResolved' : 'nothing')); + + expect(result).toEqual({ + ...item, + elements: { ...item.elements, testElementResolved: { ...item.elements.testElementResolved, value: 69 } }, + }); + }); + + it('Uses camelCase resolver when no resolver is provided', async () => { + const item: IContentItem = { + system, + elements: { + testElement: { + type: ElementType.Number, + name: 'number element', + value: 42, + }, + }, + }; + const update: IUpdateMessageData = { + item: { id: item.system.id, codename: item.system.codename }, + variant: { id: '87767c98-3d1d-490f-bd19-e0157157d087', codename: item.system.language }, + projectId: '5f53475c-de51-4cef-b373-463d56919cec', + elements: [ + { + type: ElementType.Number, + element: { id: '467dc8c1-4fcb-4adc-a5fc-049d17ee1386', codename: 'test_element' }, + data: { value: 69 }, + }, + ], + }; + + const result = await callTestFnc(item, update); + + expect(result).toEqual({ + ...item, + elements: { ...item.elements, testElement: { ...item.elements.testElement, value: 69 } }, + }); + }); + + it('Applies the update recursively on components', async () => { + type ElementsType = { + component: Elements.RichTextElement; + }; + + const item: IContentItem = { + system, + elements: { + component: { + type: ElementType.RichText, + name: 'withComponent', + value: + '', + links: [], + images: [], + linkedItemCodenames: ['e52e6e70-2d67-4b1a-84d3-c8c69dc64055'], + linkedItems: [ + { + system: { ...system, codename: 'e52e6e70-2d67-4b1a-84d3-c8c69dc64055' }, + elements: { + num: { + type: ElementType.Number, + name: 'number element', + value: 42, + }, + inner: { + type: ElementType.RichText, + name: 'inner rich text element', + value: '', + links: [], + images: [], + linkedItemCodenames: [], + linkedItems: [ + { + system: { ...system, codename: '7570d883-51ab-40fc-8409-fed5574eb7dc' }, + elements: { + num2: { + type: ElementType.Number, + name: 'number element', + value: 69, + }, + }, + }, + ], + } as Elements.RichTextElement, + }, + }, + ], + }, + }, + }; + const update: IUpdateMessageData = { + item: { id: item.system.id, codename: item.system.codename }, + variant: { id: '87767c98-3d1d-490f-bd19-e0157157d087', codename: item.system.language }, + projectId: '5f53475c-de51-4cef-b373-463d56919cec', + elements: [ + { + type: ElementType.RichText, + element: { id: '467dc8c1-4fcb-4adc-a5fc-049d17ee1386', codename: 'component' }, + data: { + value: ``, + links: [], + images: [], + linkedItemCodenames: ['e52e6e70-2d67-4b1a-84d3-c8c69dc64055'], + linkedItems: [ + { + system: { ...system, codename: 'e52e6e70-2d67-4b1a-84d3-c8c69dc64055' }, + elements: { + num: { + type: ElementType.Number, + name: 'number element', + value: 142, + }, + inner: { + type: ElementType.RichText, + name: 'inner rich text element', + value: '', + links: [], + images: [], + linkedItemCodenames: [], + linkedItems: [ + { + system: { ...system, codename: '7570d883-51ab-40fc-8409-fed5574eb7dc' }, + elements: { + num2: { + type: ElementType.Number, + name: 'number element', + value: 69, + }, + }, + }, + ], + } as Elements.RichTextElement, + }, + }, + ], + }, + }, + ], + }; + + const result = await callTestFnc(item, update); + + expect(result).toEqual({ + ...item, + elements: { + component: { + ...item.elements.component, + linkedItems: [ + { + ...item.elements.component.linkedItems[0], + elements: { + ...item.elements.component.linkedItems[0].elements, + num: { ...item.elements.component.linkedItems[0].elements.num, value: 142 }, + }, + }, + ], + }, + }, + }); + // The unchanged inner component should be the same object + expect(result.elements.component.linkedItems[0].elements.inner).toBe( + item.elements.component.linkedItems[0].elements.inner + ); + }); + }); +}); + +describe('applyUpdateOnItemAndLoadLinkedItems', () => { + it('Uses the provided argument to load newly added linked items in linkedItem and richText elements', async () => { + const addedItemLinkedItems: IContentItem = { + system: { ...system, id: 'ae0aedca-08ce-43bf-96aa-c07a4180633a', codename: 'linked_item' }, + elements: { + num: { + type: ElementType.Number, + name: 'number element', + value: 42, + }, + }, + }; + const addedItemRichText: IContentItem = { + system: { ...system, id: '97811c7f-58d1-45ed-8460-593fc8ce5d06', codename: 'rich_item' }, + elements: { + num: { + type: ElementType.Text, + name: 'text element', + value: 'some value', + }, + }, + }; + const item: IContentItem = { + system, + elements: { + linked: { + type: ElementType.ModularContent, + name: 'linked items element', + value: [], + linkedItems: [], + } as Elements.LinkedItemsElement, + rich: { + type: ElementType.RichText, + name: 'rich text element', + value: '', + images: [], + links: [], + linkedItems: [], + linkedItemCodenames: [], + } as Elements.RichTextElement, + }, + }; + const update: IUpdateMessageData = { + item: { id: item.system.id, codename: item.system.codename }, + projectId: 'b281f613-2628-4700-88d0-2dc84d2cdfd1', + variant: { id: 'b6c2f05d-5491-4387-9d4c-c9b70e660114', codename: item.system.language }, + elements: [ + { + type: ElementType.ModularContent, + element: { id: '2e8a2e80-75fe-4ba8-a2ff-d7aa48c0a6d4', codename: 'linked' }, + data: { value: [addedItemLinkedItems.system.codename], linkedItems: [] }, + }, + { + type: ElementType.RichText, + element: { id: 'cdf1ced3-b5ee-471d-a498-815b7debe4a4', codename: 'rich' }, + data: { + value: ``, + links: [], + images: [], + linkedItemCodenames: [addedItemRichText.system.codename], + linkedItems: [], + }, + }, + ], + }; + + const newItemsByCodename = new Map([ + [addedItemRichText.system.codename, addedItemRichText], + [addedItemLinkedItems.system.codename, addedItemLinkedItems], + ]); + const result = await applyUpdateOnItemAndLoadLinkedItems(item, update, (cs) => + delay(1).then(() => + Promise.all( + cs.flatMap((c) => { + const addedItem = newItemsByCodename.get(c); + return addedItem ? [Promise.resolve(addedItem)] : []; + }) + ) + ) + ); + + expect(result.elements.linked).toEqual({ + ...item.elements.linked, + value: [addedItemLinkedItems.system.codename], + linkedItems: [addedItemLinkedItems], + } as Elements.LinkedItemsElement); + + expect(result.elements.rich).toEqual({ + ...item.elements.rich, + value: ``, + linkedItemCodenames: [addedItemRichText.system.codename], + linkedItems: [addedItemRichText], + } as Elements.RichTextElement); + }); +}); + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));