Skip to content

Commit 9496491

Browse files
Merge pull request #547 from contentstack/VB-694+sync
feat: implement isValidCslp function for CSLP value validation
2 parents 726a417 + bbfcf29 commit 9496491

21 files changed

Lines changed: 258 additions & 53 deletions

src/cslp/__test__/cslpdata.test.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,124 @@
1-
import { extractDetailsFromCslp } from "../cslpdata";
1+
import { extractDetailsFromCslp, isValidCslp } from "../cslpdata";
2+
3+
describe("isValidCslp", () => {
4+
describe("valid cases", () => {
5+
test("should return true for valid v1 format with 3 parts", () => {
6+
expect(
7+
isValidCslp("content_type_uid.entry_uid.locale")
8+
).toBeTruthy();
9+
});
10+
11+
test("should return true for valid v1 format with field path", () => {
12+
expect(
13+
isValidCslp("content_type_uid.entry_uid.locale.field_path")
14+
).toBeTruthy();
15+
});
16+
17+
test("should return true for valid v2 format with 3 parts", () => {
18+
expect(
19+
isValidCslp("v2:content_type_uid.entry_uid_variant_uid.locale")
20+
).toBeTruthy();
21+
});
22+
23+
test("should return true for valid v2 format with field path", () => {
24+
expect(
25+
isValidCslp(
26+
"v2:content_type_uid.entry_uid_variant_uid.locale.field_path"
27+
)
28+
).toBeTruthy();
29+
});
30+
});
31+
32+
describe("invalid cases", () => {
33+
test("should return false for null", () => {
34+
expect(isValidCslp(null)).toBeFalsy();
35+
});
36+
37+
test("should return false for undefined", () => {
38+
expect(isValidCslp(undefined)).toBeFalsy();
39+
});
40+
41+
test("should return false for empty string", () => {
42+
expect(isValidCslp("")).toBeFalsy();
43+
});
44+
45+
test("should return false for string with less than 3 parts", () => {
46+
expect(isValidCslp("invalid")).toBeFalsy();
47+
});
48+
49+
test("should return false for string with only 2 parts", () => {
50+
expect(isValidCslp("a.b")).toBeFalsy();
51+
});
52+
53+
test("should return false for v2 prefix with no data", () => {
54+
expect(isValidCslp("v2:")).toBeFalsy();
55+
});
56+
57+
test("should return false for v2 prefix with only 2 parts", () => {
58+
expect(isValidCslp("v2:a.b")).toBeFalsy();
59+
});
60+
61+
test("should return false for v2 format where entry_uid_variant_uid has no underscore", () => {
62+
expect(
63+
isValidCslp("v2:content_type_uid.entry.locale")
64+
).toBeFalsy();
65+
});
66+
67+
test("should return false for v2 format where entry_uid_variant_uid is missing variant_uid", () => {
68+
expect(
69+
isValidCslp("v2:content_type_uid.entry_.locale")
70+
).toBeFalsy();
71+
});
72+
73+
test("should return false for v2 format where entry_uid_variant_uid is missing entry_uid", () => {
74+
expect(
75+
isValidCslp("v2:content_type_uid._variant_uid.locale")
76+
).toBeFalsy();
77+
});
78+
79+
test("should return false for v2 format with empty parts", () => {
80+
expect(isValidCslp("v2:..locale")).toBeFalsy();
81+
});
82+
83+
test("should return false for v2 format with empty content_type_uid", () => {
84+
expect(isValidCslp("v2:.entry_uid_variant_uid.locale")).toBeFalsy();
85+
});
86+
87+
test("should return false for v2 format with empty locale", () => {
88+
expect(
89+
isValidCslp("v2:content_type_uid.entry_uid_variant_uid.")
90+
).toBeFalsy();
91+
});
92+
93+
test("should return false for v1 format with empty parts", () => {
94+
expect(isValidCslp("..locale")).toBeFalsy();
95+
});
96+
97+
test("should return false for v1 format with empty content_type_uid", () => {
98+
expect(isValidCslp(".entry_uid.locale")).toBeFalsy();
99+
});
100+
101+
test("should return false for v1 format with empty entry_uid", () => {
102+
expect(isValidCslp("content_type_uid..locale")).toBeFalsy();
103+
});
104+
105+
test("should return false for whitespace-only string", () => {
106+
expect(isValidCslp(" ")).toBeFalsy();
107+
});
108+
109+
test("should return false for tab and newline whitespace", () => {
110+
expect(isValidCslp("\t\n")).toBeFalsy();
111+
});
112+
113+
test("should return false for string with only dots", () => {
114+
expect(isValidCslp("...")).toBeFalsy();
115+
});
116+
117+
test("should return false for string with only two dots", () => {
118+
expect(isValidCslp("..")).toBeFalsy();
119+
});
120+
});
121+
});
2122

3123
describe("extractDetailsFromCslp", () => {
4124
test("should extract details from a CSLP value string with nested multiple field", () => {

src/cslp/cslpdata.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,76 @@ import Config from "../configManager/configManager";
88
import { DeepSignal } from "deepsignal";
99
import { cslpTagStyles } from "../livePreview/editButton/editButton.style";
1010

11+
/**
12+
* Validates that the required CSLP parts (content_type_uid, entry_uid/entry_uid_variant_uid, locale) are non-empty.
13+
* @param parts The array of parts from splitting the CSLP string by "."
14+
* @returns `true` if all required parts (first 3) are non-empty, `false` otherwise.
15+
*/
16+
function areRequiredPartsNonEmpty(parts: string[]): boolean {
17+
// Check that we have at least 3 parts
18+
if (parts.length < 3) {
19+
return false;
20+
}
21+
// Verify that content_type_uid (parts[0]), entry_uid/entry_uid_variant_uid (parts[1]), and locale (parts[2]) are all non-empty
22+
return parts[0].length > 0 && parts[1].length > 0 && parts[2].length > 0;
23+
}
24+
25+
/**
26+
* Validates if a CSLP value string is valid.
27+
*
28+
* Supports two formats:
29+
* - **v1 format**: `content_type_uid.entry_uid.locale[.field_path]` (requires at least 3 parts)
30+
* - **v2 format**: `v2:content_type_uid.entry_uid_variant_uid.locale[.field_path]`
31+
* (requires at least 3 parts, entry_uid_variant_uid must contain underscore separating entry_uid and variant_uid)
32+
*
33+
* @param cslpValue The CSLP value string to validate (can be null or undefined).
34+
* @returns Type predicate: `true` if the CSLP value is valid (narrows type to `string`), `false` otherwise.
35+
*
36+
* @example
37+
* Valid v1 format
38+
* isValidCslp("page.entry123.en-us") -> true
39+
* isValidCslp("page.entry123.en-us.title") -> true
40+
*
41+
* Valid v2 format
42+
* isValidCslp("v2:page.entry123_variant456.en-us") -> true
43+
* isValidCslp("v2:page.entry123_variant456.en-us.title") -> true
44+
*
45+
* Invalid cases
46+
* isValidCslp(null) -> false
47+
* isValidCslp("invalid") -> false (less than 3 parts)
48+
* isValidCslp("v2:page.entry123.en-us") -> false (missing underscore in entry_uid_variant_uid)
49+
*/
50+
export function isValidCslp(
51+
cslpValue: string | null | undefined
52+
): cslpValue is string {
53+
// Return false for null, undefined, or empty string
54+
if (!cslpValue) {
55+
return false;
56+
}
57+
58+
// Check for v2 format (starts with "v2:")
59+
if (cslpValue.startsWith("v2:")) {
60+
const dataAfterPrefix = cslpValue.substring(3); // Remove "v2:" prefix
61+
const parts = dataAfterPrefix.split(".");
62+
// v2 format requires at least 3 parts: content_type_uid.entry_uid_variant_uid.locale
63+
// Verify that content_type_uid, entry_uid_variant_uid, and locale are all non-empty
64+
if (!areRequiredPartsNonEmpty(parts)) {
65+
return false;
66+
}
67+
// Verify that entry_uid_variant_uid (second part) contains both entry_uid and variant_uid separated by at least one underscore
68+
const entryUidVariantUid = parts[1];
69+
const entryVariantParts = entryUidVariantUid.split("_");
70+
// Check that we have at least 2 parts (entry_uid and variant_uid) and all parts are non-empty
71+
return entryVariantParts.length >= 2 && entryVariantParts.every((part) => part.length > 0);
72+
}
73+
74+
// v1 format (default, no prefix)
75+
const parts = cslpValue.split(".");
76+
// v1 format requires at least 3 parts: content_type_uid.entry_uid.locale
77+
// Verify that content_type_uid, entry_uid, and locale are all non-empty
78+
return areRequiredPartsNonEmpty(parts);
79+
}
80+
1181
/**
1282
* Extracts details from a CSLP value string.
1383
* @param cslpValue The CSLP value string to extract details from.
@@ -163,7 +233,7 @@ export function addCslpOutline(
163233

164234
const cslpTag = element.getAttribute("data-cslp");
165235

166-
if (trigger && cslpTag) {
236+
if (trigger && isValidCslp(cslpTag)) {
167237
if (elements.highlightedElement)
168238
elements.highlightedElement.classList.remove(
169239
cslpTagStyles()["cslp-edit-mode"]

src/livePreview/editButton/editButton.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { effect } from "@preact/signals";
22
import { inIframe, isOpeningInNewTab } from "../../common/inIframe";
33
import Config from "../../configManager/configManager";
4-
import { addCslpOutline, extractDetailsFromCslp } from "../../cslp";
4+
import { addCslpOutline, extractDetailsFromCslp, isValidCslp } from "../../cslp";
55
import { cslpTagStyles } from "./editButton.style";
66
import { PublicLogger } from "../../logger/logger";
77
import {
@@ -439,7 +439,7 @@ export class LivePreviewEditButton {
439439

440440
const cslpTag = this.tooltip.getAttribute("current-data-cslp");
441441

442-
if (cslpTag) {
442+
if (isValidCslp(cslpTag)) {
443443
const {
444444
content_type_uid,
445445
entry_uid,

src/timeline/compare/compare.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import timelinePostMessage from "../timelinePostMessage/timelinePostMessage";
22
import { timelinePostMessageEvents } from "../timelinePostMessage/timelinePostMessage.constant";
33
import { compareGlobalStyles } from "./compare.style";
4+
import { isValidCslp } from "../../cslp/cslpdata";
45

56
const voidElements = new Set([
67
"area",
@@ -64,7 +65,8 @@ export function handleWebCompare() {
6465
);
6566
const map: Record<string, string> = {};
6667
for (const element of elements) {
67-
const cslp = element.getAttribute("data-cslp")!;
68+
const cslp = element.getAttribute("data-cslp");
69+
if (!isValidCslp(cslp)) continue;
6870
if (
6971
element.hasAttributes() &&
7072
voidElements.has(element.tagName.toLowerCase())
@@ -101,8 +103,8 @@ export function handleWebCompare() {
101103
document.querySelectorAll(LEAF_CSLP_SELECTOR)
102104
);
103105
for (const element of elements) {
104-
const path = element.getAttribute("data-cslp")!;
105-
if (!diff[path]) continue;
106+
const path = element.getAttribute("data-cslp");
107+
if (!isValidCslp(path) || !diff[path]) continue;
106108

107109
if (voidElements.has(element.tagName.toLowerCase())) {
108110
element.classList.add(`cs-compare__void--${operation}`);

src/visualBuilder/components/fieldLabelWrapper.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import classNames from "classnames";
22
import React, { useEffect, useState } from "preact/compat";
3-
import { extractDetailsFromCslp } from "../../cslp";
3+
import { extractDetailsFromCslp, isValidCslp } from "../../cslp";
44
import { CslpData } from "../../cslp/types/cslp.types";
55
import { VisualBuilderCslpEventDetails } from "../types/visualBuilder.types";
66
import { FieldSchemaMap } from "../utils/fieldSchemaMap";
@@ -144,7 +144,7 @@ function FieldLabelWrapperComponent(
144144
const domAncestor = eventDetails.editableElement.closest(`[data-cslp]:not([data-cslp^="${props.fieldMetadata.content_type_uid}"])`);
145145
if(domAncestor) {
146146
const domAncestorCslp = domAncestor.getAttribute("data-cslp");
147-
if (domAncestorCslp) {
147+
if (isValidCslp(domAncestorCslp)) {
148148
const domAncestorDetails = extractDetailsFromCslp(domAncestorCslp);
149149
const domAncestorContentTypeUid = domAncestorDetails.content_type_uid;
150150
const domAncestorContentParent = referenceData?.find(data => data.contentTypeUid === domAncestorContentTypeUid);

src/visualBuilder/eventManager/__test__/useRevalidateFieldDataPostMessageEvent.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
22
import { VisualBuilder } from "../..";
33
import { FieldSchemaMap } from "../../utils/fieldSchemaMap";
44
import { handleRevalidateFieldData } from "../useRevalidateFieldDataPostMessageEvent";
5-
5+
import * as cslpdata from "../../../cslp";
66
// Mock dependencies
77
vi.mock("../../utils/fieldSchemaMap", () => ({
88
FieldSchemaMap: {
@@ -11,9 +11,7 @@ vi.mock("../../utils/fieldSchemaMap", () => ({
1111
},
1212
}));
1313

14-
vi.mock("../../../cslp", () => ({
15-
extractDetailsFromCslp: vi.fn(),
16-
}));
14+
vi.spyOn(cslpdata, "extractDetailsFromCslp");
1715

1816
vi.mock("../../generators/generateOverlay", () => ({
1917
hideFocusOverlay: vi.fn(),

src/visualBuilder/eventManager/__test__/useVariantsPostMessageEvent.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ describe("useVariantFieldsPostMessageEvent", () => {
153153

154154
// Reset mocks
155155
vi.clearAllMocks();
156+
157+
// Mock isValidCslp to return true for test data (after clearAllMocks)
158+
vi.spyOn(cslpdata, "isValidCslp").mockReturnValue(true);
156159
});
157160

158161
afterEach(() => {
@@ -362,6 +365,9 @@ describe("addVariantFieldClass", () => {
362365

363366
// Reset mocks
364367
vi.clearAllMocks();
368+
369+
// Mock isValidCslp to return true for test data
370+
vi.spyOn(cslpdata, "isValidCslp").mockReturnValue(true);
365371
});
366372

367373
afterEach(() => {
@@ -423,6 +429,7 @@ describe("addVariantFieldClass", () => {
423429
variant: cslpValue.split(":")[1]
424430
}
425431
});
432+
426433
const variantUid = "variant-456";
427434
const variantOrder = ["variant-123", "variant-456"];
428435
VisualBuilder.VisualBuilderGlobalState.value.variantOrder = variantOrder;

src/visualBuilder/eventManager/useRecalculateVariantDataCSLPValues.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import livePreviewPostMessage from "../../livePreview/eventManager/livePreviewEv
33
import { LIVE_PREVIEW_POST_MESSAGE_EVENTS } from "../../livePreview/eventManager/livePreviewEventManager.constant";
44
import { DATA_CSLP_ATTR_SELECTOR } from "../utils/constants";
55
import { visualBuilderStyles } from "../visualBuilder.style";
6+
import { isValidCslp } from "../../cslp/cslpdata";
67
import { setHighlightVariantFields } from "./useVariantsPostMessageEvent";
78

89
const VARIANT_UPDATE_DELAY_MS: Readonly<number> = 8000;
@@ -37,7 +38,7 @@ export function updateVariantClasses(): void {
3738
dataCslp: string,
3839
observer?: MutationObserver
3940
) => {
40-
if (!dataCslp) return;
41+
if (!isValidCslp(dataCslp)) return;
4142

4243
if (
4344
dataCslp.startsWith("v2:") &&
@@ -84,7 +85,7 @@ export function updateVariantClasses(): void {
8485
const addElementClasses = (element: HTMLElement) => {
8586
const dataCslp = element.getAttribute(DATA_CSLP_ATTR_SELECTOR);
8687

87-
if (!dataCslp) {
88+
if (!isValidCslp(dataCslp)) {
8889
//recursive call for child nodes
8990
element.childNodes.forEach((child) => {
9091
if (child instanceof HTMLElement) {

src/visualBuilder/eventManager/useRevalidateFieldDataPostMessageEvent.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { VisualBuilder } from "..";
2-
import { extractDetailsFromCslp } from "../../cslp";
2+
import { extractDetailsFromCslp, isValidCslp } from "../../cslp";
33
import { FieldSchemaMap } from "../utils/fieldSchemaMap";
44
import { hideFocusOverlay } from "../generators/generateOverlay";
55
import { handleBuilderInteraction } from "../listeners/mouseClick";
@@ -32,7 +32,7 @@ export async function handleRevalidateFieldData(): Promise<void> {
3232

3333
if (targetElement) {
3434
const cslp = targetElement.getAttribute("data-cslp");
35-
if (cslp) {
35+
if (isValidCslp(cslp)) {
3636
const fieldMetadata = extractDetailsFromCslp(cslp);
3737

3838
// Try to revalidate specific field schema and data
@@ -51,7 +51,7 @@ export async function handleRevalidateFieldData(): Promise<void> {
5151
window.location.reload();
5252
} finally {
5353
// Step 3: Refocus the element if we had one focused before
54-
if (shouldRefocus && elementCslp) {
54+
if (shouldRefocus && isValidCslp(elementCslp)) {
5555
await refocusElement(elementCslp, elementCslpUniqueId);
5656
}
5757
}

src/visualBuilder/eventManager/useVariantsPostMessageEvent.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types
55
import { FieldSchemaMap } from "../utils/fieldSchemaMap";
66
import { updateVariantClasses } from "./useRecalculateVariantDataCSLPValues";
77
import { debounce } from "lodash-es";
8-
import { extractDetailsFromCslp } from "../../cslp/cslpdata";
8+
import { extractDetailsFromCslp, isValidCslp } from "../../cslp/cslpdata";
99

1010
interface VariantFieldsEvent {
1111
data: {
@@ -61,7 +61,7 @@ export function addVariantFieldClass(
6161
const elements = document.querySelectorAll(`[data-cslp]`);
6262
elements.forEach((element) => {
6363
const dataCslp = element.getAttribute("data-cslp");
64-
if (!dataCslp) return;
64+
if (!isValidCslp(dataCslp)) return;
6565

6666
if (dataCslp?.includes(variant_uid)) {
6767
element.classList.add("visual-builder__variant-field");

0 commit comments

Comments
 (0)