Skip to content
Draft
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
1,924 changes: 840 additions & 1,084 deletions package-lock.json

Large diffs are not rendered by default.

36 changes: 17 additions & 19 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,28 +46,26 @@
},
"homepage": "https://github.com/kontent-ai/smart-link#readme",
"devDependencies": {
"@babel/core": "7.18.10",
"@playwright/experimental-ct-react": "^1.50.1",
"@playwright/test": "^1.50.1",
"@babel/core": "7.26.10",
"@playwright/experimental-ct-react": "^1.51.0",
"@playwright/test": "^1.51.0",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "5.33.0",
"@typescript-eslint/parser": "5.33.0",
"@typescript-eslint/eslint-plugin": "8.26.1",
"@typescript-eslint/parser": "8.26.1",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/browser": "^3.0.5",
"eslint": "8.21.0",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-prettier": "4.2.1",
"playwright": "^1.50.1",
"prettier": "2.7.1",
"@vitest/browser": "^3.0.8",
"eslint": "^8.57.1",
"eslint-config-prettier": "10.1.1",
"eslint-plugin-prettier": "5.2.3",
"@kontent-ai/delivery-sdk": "^16.0.0",
"playwright": "^1.51.0",
"prettier": "3.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rimraf": "3.0.2",
"rimraf": "6.0.1",
"rollup-plugin-visualizer": "^5.14.0",
"typescript": "4.7.4",
"vite": "^6.1.0",
"vitest": "^3.0.5"
},
"dependencies": {
"@kontent-ai/delivery-sdk": "^14.10.0"
"typescript": "5.8.2",
"vite": "^6.2.1",
"vitest": "^3.0.8"
}
}
}
6 changes: 6 additions & 0 deletions src/lib/ConfigurationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface IKSLPublicConfiguration {
* outside Web Spotlight.
*/
readonly queryParam: string;

readonly cspNonce?: string;
}

export interface IKSLPrivateConfiguration {
Expand All @@ -43,6 +45,7 @@ export interface IConfigurationManager {
readonly defaultProjectId?: string;
readonly isInsideWebSpotlightPreviewIFrame: boolean;
readonly queryParam?: string;
readonly cspNonce?: string;
readonly update: (configuration?: Partial<KSLConfiguration>) => void;
}

Expand Down Expand Up @@ -75,6 +78,9 @@ export class ConfigurationManager implements IConfigurationManager {
public get defaultProjectId(): string | undefined {
return this.configuration.defaultDataAttributes.projectId;
}
public get cspNonce(): string | undefined {
return this.configuration.cspNonce;
}

public get isInsideWebSpotlightPreviewIFrame(): boolean {
const { forceWebSpotlightMode, isInsideWebSpotlight } = this.configuration;
Expand Down
2 changes: 1 addition & 1 deletion src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ class KontentSmartLinkSDK {
}

public initialize = async (): Promise<void> => {
await defineAllRequiredWebComponents();
await defineAllRequiredWebComponents(this.configurationManager.cspNonce);

const level = this.configurationManager.debug ? LogLevel.Debug : LogLevel.Info;
Logger.setLogLevel(level);
Expand Down
90 changes: 18 additions & 72 deletions src/utils/liveReload.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
import {
camelCasePropertyNameResolver,
ElementModels,
Elements,
ElementType,
IContentItem,
IContentItemElements,
} from '@kontent-ai/delivery-sdk';
import { ElementModels, Elements, ElementType, IContentItem, IContentItemElements } from '@kontent-ai/delivery-sdk';
import {
CustomElementUpdateData,
DatetimeElementUpdateData,
Expand All @@ -23,52 +16,36 @@ import {
OptionallyAsync,
} from './liveReload/optionallyAsync';

const defaultCodenameResolver = (codename: string) => camelCasePropertyNameResolver('', codename);

export const applyUpdateOnItem = <Elements extends IContentItemElements>(
item: IContentItem<Elements>,
update: IUpdateMessageData,
resolveElementCodename: (codename: string) => string = defaultCodenameResolver
): IContentItem<Elements> =>
evaluateOptionallyAsync(applyUpdateOnItemOptionallyAsync(item, update, resolveElementCodename), null);
update: IUpdateMessageData
): IContentItem<Elements> => evaluateOptionallyAsync(applyUpdateOnItemOptionallyAsync(item, update), null);

export const applyUpdateOnItemAndLoadLinkedItems = <Elements extends IContentItemElements>(
item: IContentItem<Elements>,
update: IUpdateMessageData,
fetchItems: (itemCodenames: ReadonlyArray<string>) => Promise<ReadonlyArray<IContentItem>>,
resolveElementCodename: (codename: string) => string = defaultCodenameResolver
fetchItems: (itemCodenames: ReadonlyArray<string>) => Promise<ReadonlyArray<IContentItem>>
): Promise<IContentItem<Elements>> =>
Promise.resolve(
evaluateOptionallyAsync(applyUpdateOnItemOptionallyAsync(item, update, resolveElementCodename), fetchItems)
);
Promise.resolve(evaluateOptionallyAsync(applyUpdateOnItemOptionallyAsync(item, update), fetchItems));

const applyUpdateOnItemOptionallyAsync = <Elements extends IContentItemElements>(
item: IContentItem<Elements>,
update: InternalUpdateMessage,
resolveElementCodename: (codename: string) => string,
updatedItem: IContentItem<Elements> | null = null,
processedItemsPath: ReadonlyArray<string> = []
): OptionallyAsync<IContentItem<Elements>> => {
const shouldApplyOnThisItem =
item.system.codename === update.item.codename && item.system.language === update.variant.codename;

const elementUpdates = update.elements.map((u) => ({
...u,
element: {
...u.element,
codename: resolveElementCodename(u.element.codename),
},
}));

const newUpdatedItem = !updatedItem && shouldApplyOnThisItem ? { ...item } : updatedItem; // We will mutate its elements to new values before returning. This is necesary to preserve cyclic dependencies between items without infinite recursion.

const updatedElements = mergeOptionalAsyncs(
Object.entries(item.elements).map(([elementCodename, element]) => {
const matchingUpdate = elementUpdates.find((u) => u.element.codename === elementCodename);
const matchingUpdate = update.elements.find((u) => u.element.codename === elementCodename);

if (shouldApplyOnThisItem && matchingUpdate) {
return applyOnOptionallyAsync(
applyUpdateOnElement(element, matchingUpdate, resolveElementCodename),
applyUpdateOnElement(element, matchingUpdate),
(newElement) => [elementCodename, newElement] as const
);
}
Expand All @@ -89,7 +66,7 @@ const applyUpdateOnItemOptionallyAsync = <Elements extends IContentItemElements>
updatedItem?.system.codename ?? null
)
? createOptionallyAsync(() => i) // we found a cycle that doesn't need any update so we just ignore it
: applyUpdateOnItemOptionallyAsync(i, update, resolveElementCodename, newUpdatedItem, [
: applyUpdateOnItemOptionallyAsync(i, update, newUpdatedItem, [
...processedItemsPath,
i.system.codename,
]);
Expand Down Expand Up @@ -127,8 +104,7 @@ const closesCycleWithoutUpdate = (path: ReadonlyArray<string>, nextItem: string,

const applyUpdateOnElement = (
element: ElementModels.IElement<unknown>,
update: InternalUpdateElementMessage,
resolveCodenames: (codename: string) => string
update: InternalUpdateElementMessage
): OptionallyAsync<ElementModels.IElement<unknown>> => {
switch (update.type) {
case ElementType.Text:
Expand All @@ -140,7 +116,7 @@ const applyUpdateOnElement = (
case ElementType.ModularContent:
return applyLinkedItemsElement(element as Elements.LinkedItemsElement, update);
case ElementType.RichText:
return applyRichTextElement(element as Elements.RichTextElement, update, resolveCodenames);
return applyRichTextElement(element as Elements.RichTextElement, update);
case ElementType.MultipleChoice:
return createOptionallyAsync(() =>
applyArrayElement(element as Elements.MultipleChoiceElement, update, (o1, o2) => o1?.codename === o2?.codename)
Expand Down Expand Up @@ -213,8 +189,7 @@ const applyLinkedItemsElement = (

const applyRichTextElement = (
element: Elements.RichTextElement,
update: RichTextElementUpdateData,
resolveCodenames: (codename: string) => string
update: RichTextElementUpdateData
): OptionallyAsync<Elements.RichTextElement> => {
if (areRichTextElementsSame(element, update.data)) {
return createOptionallyAsync(() => element);
Expand All @@ -225,7 +200,6 @@ const applyRichTextElement = (
update.data.linkedItemCodenames,
update.data.linkedItems
.filter((i) => !element.linkedItems.find((u) => u.system.codename === i.system.codename))
.map(applyCodenameResolver(resolveCodenames))
.concat(element.linkedItems)
),
(linkedItems) => ({
Expand All @@ -239,10 +213,10 @@ const applyRichTextElement = (
);

return chainOptionallyAsync(withItems, (el) =>
applyOnOptionallyAsync(
updateComponents(update.data.linkedItems, el.linkedItems, resolveCodenames),
(linkedItems) => ({ ...el, linkedItems })
)
applyOnOptionallyAsync(updateComponents(update.data.linkedItems, el.linkedItems), (linkedItems) => ({
...el,
linkedItems,
}))
);
};

Expand Down Expand Up @@ -322,17 +296,13 @@ const areRichTextElementsSame = (
el1.linkedItems.length === el2.linkedItems.length &&
el1.linkedItems.every((item, i) => areItemsSame(item, el2.linkedItems[i]));

const updateComponents = (
newItems: ReadonlyArray<IContentItem>,
oldItems: ReadonlyArray<IContentItem>,
resolveCodenames: (codename: string) => string
) =>
const updateComponents = (newItems: ReadonlyArray<IContentItem>, oldItems: ReadonlyArray<IContentItem>) =>
mergeOptionalAsyncs(
oldItems.map((item) => {
const newItem = newItems.find((i) => i.system.codename === item.system.codename);

return newItem
? applyUpdateOnItemOptionallyAsync(item, convertItemToUpdate(newItem), resolveCodenames)
? applyUpdateOnItemOptionallyAsync(item, convertItemToUpdate(newItem))
: createOptionallyAsync(() => item);
})
);
Expand All @@ -348,7 +318,7 @@ const updateLinkedItems = (newValue: ReadonlyArray<string>, loadedItems: Readonl
const fetchedItems = new Map(fetchedItemsArray.map((i) => [i.system.codename, i] as const));

return newLinkedItems
.map((codename) => (isString(codename) ? fetchedItems.get(codename) ?? null : codename))
.map((codename) => (isString(codename) ? (fetchedItems.get(codename) ?? null) : codename))
.filter(notNull);
}
);
Expand Down Expand Up @@ -419,27 +389,3 @@ const convertItemToUpdate = (item: IContentItem): InternalUpdateMessage => ({
}
}),
});

const applyCodenameResolver =
(resolver: (codename: string) => string) =>
(item: IContentItem): IContentItem => ({
...item,
elements: Object.fromEntries(
Object.entries(item.elements).map(([codename, element]) => {
switch (element.type) {
case ElementType.ModularContent:
case ElementType.RichText:
type Element = Elements.LinkedItemsElement | Elements.RichTextElement;
return [
resolver(codename),
{
...element,
linkedItems: (element as Element).linkedItems.map(applyCodenameResolver(resolver)),
},
];
default:
return [resolver(codename), element];
}
})
),
});
36 changes: 21 additions & 15 deletions src/web-components/KSLAddButtonElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,14 @@ declare global {
}
}

const getPopoverHtml = ({ elementType, isParentPublished, permissions }: IAddButtonPermissionsServerModel) => {
const canUserCreateLinkedItem = permissions.get(AddButtonPermission.CreateNew) === AddButtonPermissionCheckResult.Ok;

return `
<style>
const getPopoverHtml =
(cspNonce?: string) =>
({ elementType, isParentPublished, permissions }: IAddButtonPermissionsServerModel) => {
const canUserCreateLinkedItem =
permissions.get(AddButtonPermission.CreateNew) === AddButtonPermissionCheckResult.Ok;

return `
<style ${cspNonce ? `nonce=${cspNonce}` : ''}>
.ksl-add-button__popover-button + .ksl-add-button__popover-button {
margin-left: 4px;
}
Expand Down Expand Up @@ -105,7 +108,7 @@ const getPopoverHtml = ({ elementType, isParentPublished, permissions }: IAddBut
<ksl-icon icon-name="${IconName.CollapseScheme}"/>
</ksl-button>
`;
};
};

const templateHTML = `
<style>
Expand All @@ -130,6 +133,8 @@ const templateHTML = `
`;

export class KSLAddButtonElement extends KSLPositionedElement {
private static PopOverElement: ((params: IAddButtonPermissionsServerModel) => string) | null = null;

public static get is() {
return 'ksl-add-button' as const;
}
Expand All @@ -148,7 +153,8 @@ export class KSLAddButtonElement extends KSLPositionedElement {
this.buttonRef = this.shadowRoot.querySelector(KSLButtonElement.is) as KSLButtonElement;
}

public static initializeTemplate(): HTMLTemplateElement {
public static initializeTemplate(cspNonce?: string): HTMLTemplateElement {
KSLAddButtonElement.PopOverElement = getPopoverHtml(cspNonce);
return createTemplateForCustomElement(templateHTML);
}

Expand All @@ -165,7 +171,7 @@ export class KSLAddButtonElement extends KSLPositionedElement {

window.removeEventListener('click', this.handleClickOutside, { capture: true });
this.buttonRef.removeEventListener('click', this.handleClick);
this.hidePopover();
this.dismissPopover();
}

public adjustPosition = (): void => {
Expand Down Expand Up @@ -263,7 +269,7 @@ export class KSLAddButtonElement extends KSLPositionedElement {
this.buttonRef.disabled = false;
this.buttonRef.tooltipMessage = DefaultTooltipMessage;

this.showPopover(response);
this.displayPopover(response);
}
} catch (reason) {
Logger.error(reason);
Expand All @@ -286,21 +292,21 @@ export class KSLAddButtonElement extends KSLPositionedElement {

const clickedInside = this.isSameNode(event.target) || this.contains(event.target);
if (!clickedInside) {
this.hidePopover();
this.dismissPopover();
}
};

private showPopover = (response: IAddButtonPermissionsServerModel): void => {
private displayPopover = (response: IAddButtonPermissionsServerModel): void => {
assert(this.shadowRoot, 'Shadow root must be available in "open" mode.');

if (this.popoverRef) {
this.hidePopover();
this.dismissPopover();
}

this.buttonRef.tooltipPosition = ElementPositionOffset.Bottom;

const popover = document.createElement(KSLPopoverElement.is);
popover.innerHTML = getPopoverHtml(response);
popover.innerHTML = KSLAddButtonElement.PopOverElement?.(response) ?? '';

const popoverParent = this.shadowRoot;

Expand All @@ -314,7 +320,7 @@ export class KSLAddButtonElement extends KSLPositionedElement {
this.popoverRef.adjustPosition();
};

private hidePopover = (): void => {
private dismissPopover = (): void => {
this.buttonRef.tooltipPosition = ElementPositionOffset.Top;

if (this.popoverRef) {
Expand Down Expand Up @@ -408,7 +414,7 @@ export class KSLAddButtonElement extends KSLPositionedElement {
},
});

this.hidePopover();
this.dismissPopover();
this.dispatchEvent(customEvent);
};
}
Loading
Loading