@dotcms/experiments 0.0.1-alpha.37 → 0.0.1-alpha.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.babelrc +12 -0
  2. package/.eslintrc.json +26 -0
  3. package/jest.config.ts +11 -0
  4. package/package.json +4 -8
  5. package/project.json +55 -0
  6. package/src/lib/components/{DotExperimentHandlingComponent.d.ts → DotExperimentHandlingComponent.tsx} +20 -3
  7. package/src/lib/components/DotExperimentsProvider.spec.tsx +62 -0
  8. package/src/lib/components/{DotExperimentsProvider.d.ts → DotExperimentsProvider.tsx} +41 -3
  9. package/src/lib/components/withExperiments.tsx +52 -0
  10. package/src/lib/contexts/DotExperimentsContext.spec.tsx +42 -0
  11. package/src/lib/contexts/{DotExperimentsContext.d.ts → DotExperimentsContext.tsx} +5 -2
  12. package/src/lib/dot-experiments.spec.ts +285 -0
  13. package/src/lib/dot-experiments.ts +716 -0
  14. package/src/lib/hooks/useExperimentVariant.spec.tsx +111 -0
  15. package/src/lib/hooks/useExperimentVariant.ts +55 -0
  16. package/src/lib/hooks/useExperiments.ts +90 -0
  17. package/src/lib/shared/{constants.d.ts → constants.ts} +35 -18
  18. package/src/lib/shared/mocks/mock.ts +209 -0
  19. package/src/lib/shared/{models.d.ts → models.ts} +35 -2
  20. package/src/lib/shared/parser/parse.spec.ts +187 -0
  21. package/src/lib/shared/parser/parser.ts +171 -0
  22. package/src/lib/shared/persistence/index-db-database-handler.spec.ts +100 -0
  23. package/src/lib/shared/persistence/index-db-database-handler.ts +218 -0
  24. package/src/lib/shared/utils/DotLogger.ts +57 -0
  25. package/src/lib/shared/utils/memoize.spec.ts +49 -0
  26. package/src/lib/shared/utils/memoize.ts +49 -0
  27. package/src/lib/shared/utils/utils.spec.ts +142 -0
  28. package/src/lib/shared/utils/utils.ts +203 -0
  29. package/src/lib/standalone.spec.ts +36 -0
  30. package/src/lib/standalone.ts +28 -0
  31. package/tsconfig.json +20 -0
  32. package/tsconfig.lib.json +20 -0
  33. package/tsconfig.spec.json +9 -0
  34. package/vite.config.ts +41 -0
  35. package/index.esm.d.ts +0 -1
  36. package/index.esm.js +0 -7174
  37. package/src/lib/components/withExperiments.d.ts +0 -20
  38. package/src/lib/dot-experiments.d.ts +0 -289
  39. package/src/lib/hooks/useExperimentVariant.d.ts +0 -21
  40. package/src/lib/hooks/useExperiments.d.ts +0 -14
  41. package/src/lib/shared/mocks/mock.d.ts +0 -43
  42. package/src/lib/shared/parser/parser.d.ts +0 -54
  43. package/src/lib/shared/persistence/index-db-database-handler.d.ts +0 -87
  44. package/src/lib/shared/utils/DotLogger.d.ts +0 -15
  45. package/src/lib/shared/utils/memoize.d.ts +0 -7
  46. package/src/lib/shared/utils/utils.d.ts +0 -73
  47. package/src/lib/standalone.d.ts +0 -7
  48. /package/src/{index.d.ts → index.ts} +0 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Represents the configuration for a database connection.
3
+ * @interface
4
+ */
5
+ import {
6
+ EXPERIMENT_ALREADY_CHECKED_KEY,
7
+ EXPERIMENT_FETCH_EXPIRE_TIME_KEY,
8
+ LOCAL_STORAGE_TIME_DURATION_MILLISECONDS
9
+ } from '../constants';
10
+
11
+ /**
12
+ * Represents the configuration for a database connection.
13
+ * @interface
14
+ */
15
+ interface DbConfig {
16
+ db_name: string;
17
+ db_store: string;
18
+ db_key_path: string;
19
+ }
20
+
21
+ /**
22
+ * The default version of the database.
23
+ *
24
+ * @type {number}
25
+ * @constant
26
+ */
27
+ const DB_DEFAULT_VERSION = 1;
28
+
29
+ /**
30
+ * The `DatabaseHandler` class offers specific methods to store and get data
31
+ * from IndexedDB.
32
+ *
33
+ * @example
34
+ * // To fetch data from the IndexedDB
35
+ * const data = await DatabaseHandler.getData();
36
+ *
37
+ * @example
38
+ * // To store an object of type AssignedExperiments to IndexedDB
39
+ * await DatabaseHandler.persistData(anAssignedExperiment);
40
+ *
41
+ * @example
42
+ * // To get an object of type AssignedExperiments to IndexedDB
43
+ * await DatabaseHandler.persistData(anAssignedExperiment);
44
+ *
45
+ */
46
+
47
+ export class IndexDBDatabaseHandler {
48
+ constructor(private config: DbConfig) {
49
+ if (!config) {
50
+ throw new Error('Config is required');
51
+ }
52
+
53
+ const { db_name, db_store, db_key_path } = config;
54
+
55
+ if (!db_name) {
56
+ throw new Error("'db_name' is required in config");
57
+ }
58
+
59
+ if (!db_store) {
60
+ throw new Error("'db_store' is required in config");
61
+ }
62
+
63
+ if (!db_key_path) {
64
+ throw new Error("'db_key_path' is required in config");
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Saves the provided data to indexDB.
70
+ *
71
+ * @async
72
+ * @param {AssignedExperiments} data - The data to be saved.
73
+ * @returns {Promise<any>} - The result of the save operation.
74
+ */
75
+ public async persistData<T>(data: T): Promise<IDBValidKey> {
76
+ const db = await this.openDB();
77
+
78
+ return await new Promise((resolve, reject) => {
79
+ const transaction = db.transaction([this.config.db_store], 'readwrite');
80
+
81
+ const store = transaction.objectStore(this.config.db_store);
82
+
83
+ const clearRequest = store.clear();
84
+
85
+ clearRequest.onerror = () => reject(clearRequest.error);
86
+ clearRequest.onsuccess = () => {
87
+ const request = store.put(data, this.config.db_key_path);
88
+
89
+ request.onsuccess = () => resolve(request.result);
90
+ request.onerror = () => reject(request.error);
91
+ };
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Retrieves data from the database using a specific key.
97
+ *
98
+ * @async
99
+ * @returns {Promise<any>} A promise that resolves with the data retrieved from the database.
100
+ */
101
+ public async getData<T>(): Promise<T> {
102
+ const db = await this.openDB();
103
+
104
+ return await new Promise((resolve, reject) => {
105
+ const transaction = db.transaction([this.config.db_store], 'readonly');
106
+
107
+ const store = transaction.objectStore(this.config.db_store);
108
+
109
+ const request = store.get(this.config.db_key_path);
110
+
111
+ request.onsuccess = () => resolve(request.result as T);
112
+ request.onerror = () => reject(request.error);
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Deletes all the data from the IndexedDB store.
118
+ *
119
+ * @async
120
+ * @returns {Promise<void>} - The result of the delete operation.
121
+ */
122
+ public async clearData(): Promise<void> {
123
+ const db = await this.openDB();
124
+
125
+ return await new Promise((resolve, reject) => {
126
+ const transaction = db.transaction([this.config.db_store], 'readwrite');
127
+
128
+ const store = transaction.objectStore(this.config.db_store);
129
+
130
+ const request = store.clear();
131
+
132
+ request.onsuccess = () => resolve();
133
+ request.onerror = () => reject(request.error);
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Sets the flag indicating that the experiment has already been checked.
139
+ *
140
+ * @function setFlagExperimentAlreadyChecked
141
+ * @returns {void}
142
+ */
143
+ setFlagExperimentAlreadyChecked(): void {
144
+ sessionStorage.setItem(EXPERIMENT_ALREADY_CHECKED_KEY, 'true');
145
+ }
146
+
147
+ /**
148
+ * Sets the fetch expired time in the local storage.
149
+ *
150
+ * @return {void}
151
+ */
152
+ setFetchExpiredTime(): void {
153
+ const expireTime = Date.now() + LOCAL_STORAGE_TIME_DURATION_MILLISECONDS;
154
+
155
+ localStorage.setItem(EXPERIMENT_FETCH_EXPIRE_TIME_KEY, expireTime.toString());
156
+ }
157
+
158
+ /**
159
+ * Builds an error message based on the provided error object.
160
+ * @param {DOMException | null} error - The error object to build the message from.
161
+ * @returns {string} The constructed error message.
162
+ */
163
+ private getOnErrorMessage(error: DOMException | null): string {
164
+ let errorMessage =
165
+ 'A database error occurred while using IndexedDB. Your browser may not support IndexedDB or IndexedDB might not be enabled.';
166
+
167
+ if (error) {
168
+ errorMessage += error.name ? ` Error Name: ${error.name}` : '';
169
+ errorMessage += error.message ? ` Error Message: ${error.message}` : '';
170
+ errorMessage += error.code ? ` Error Code: ${error.code}` : '';
171
+ }
172
+
173
+ return errorMessage;
174
+ }
175
+
176
+ /**
177
+ * Creates or opens a IndexedDB database with the specified version.
178
+ *
179
+ *
180
+ * @returns {Promise<IDBDatabase>} A promise that resolves to the opened database.
181
+ * The promise will be rejected with an error message if there was a database error.
182
+ */
183
+ private openDB(): Promise<IDBDatabase> {
184
+ return new Promise<IDBDatabase>((resolve, reject) => {
185
+ const request = indexedDB.open(this.config.db_name, DB_DEFAULT_VERSION);
186
+
187
+ request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
188
+ const db = this.getRequestResult(event);
189
+
190
+ if (!db.objectStoreNames.contains(this.config.db_store)) {
191
+ db.createObjectStore(this.config.db_store);
192
+ }
193
+ };
194
+
195
+ request.onerror = (event) => {
196
+ const errorMsj = this.getOnErrorMessage((event.target as IDBRequest).error);
197
+
198
+ reject(errorMsj);
199
+ };
200
+
201
+ request.onsuccess = (event: Event) => {
202
+ const db = this.getRequestResult(event);
203
+
204
+ resolve(db);
205
+ };
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Retrieves the result of a database request from an Event object.
211
+ *
212
+ * @param {Event} event - The Event object containing the database request.
213
+ * @returns {IDBDatabase} - The result of the database request, casted as an IDBDatabase object.
214
+ */
215
+ private getRequestResult(event: Event): IDBDatabase {
216
+ return (event.target as IDBRequest).result as IDBDatabase;
217
+ }
218
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Logger for the dotCMS SDK
3
+ */
4
+ export class DotLogger {
5
+ private readonly isDebug: boolean;
6
+ private readonly packageName: string;
7
+
8
+ constructor(isDebug: boolean, packageName: string) {
9
+ this.isDebug = isDebug;
10
+ this.packageName = packageName;
11
+ }
12
+
13
+ public group(label: string) {
14
+ if (this.isDebug) {
15
+ // eslint-disable-next-line no-console
16
+ console.group(label);
17
+ }
18
+ }
19
+
20
+ public groupEnd() {
21
+ if (this.isDebug) {
22
+ // eslint-disable-next-line no-console
23
+ console.groupEnd();
24
+ }
25
+ }
26
+
27
+ public time(label: string) {
28
+ if (this.isDebug) {
29
+ // eslint-disable-next-line no-console
30
+ console.time(label);
31
+ }
32
+ }
33
+
34
+ public timeEnd(label: string) {
35
+ if (this.isDebug) {
36
+ // eslint-disable-next-line no-console
37
+ console.timeEnd(label);
38
+ }
39
+ }
40
+
41
+ public log(message: string) {
42
+ if (this.isDebug) {
43
+ // eslint-disable-next-line no-console
44
+ console.log(`[dotCMS ${this.packageName}] ${message}`);
45
+ }
46
+ }
47
+
48
+ public warn(message: string) {
49
+ if (this.isDebug) {
50
+ console.warn(`[dotCMS ${this.packageName}] ${message}`);
51
+ }
52
+ }
53
+
54
+ public error(message: string) {
55
+ console.error(`[dotCMS ${this.packageName}] ${message}`);
56
+ }
57
+ }
@@ -0,0 +1,49 @@
1
+ import { renderHook } from '@testing-library/react-hooks';
2
+
3
+ import { useMemoizedObject } from './memoize';
4
+
5
+ describe('useMemoizedObject', () => {
6
+ it('should return the same object if it has not changed', () => {
7
+ const initialObject = { a: 1, b: 2 };
8
+
9
+ const { result, rerender } = renderHook(({ obj }) => useMemoizedObject(obj), {
10
+ initialProps: { obj: initialObject }
11
+ });
12
+
13
+ const firstRenderResult = result.current;
14
+
15
+ rerender({ obj: initialObject });
16
+ expect(result.current).toBe(firstRenderResult);
17
+ });
18
+
19
+ it('should return a new object if it has changed', () => {
20
+ const initialObject = { a: 1, b: 2 };
21
+
22
+ const newObject = { a: 1, b: 3 };
23
+
24
+ const { result, rerender } = renderHook(({ obj }) => useMemoizedObject(obj), {
25
+ initialProps: { obj: initialObject }
26
+ });
27
+
28
+ const firstRenderResult = result.current;
29
+
30
+ rerender({ obj: newObject });
31
+ expect(result.current).not.toBe(firstRenderResult);
32
+ expect(result.current).toBe(newObject);
33
+ });
34
+
35
+ it('should return the same object if a deeply equal but different reference is passed', () => {
36
+ const initialObject = { a: 1, b: 2 };
37
+
38
+ const newObject = { a: 1, b: 2 };
39
+
40
+ const { result, rerender } = renderHook(({ obj }) => useMemoizedObject(obj), {
41
+ initialProps: { obj: initialObject }
42
+ });
43
+
44
+ const firstRenderResult = result.current;
45
+
46
+ rerender({ obj: newObject });
47
+ expect(result.current).toBe(firstRenderResult);
48
+ });
49
+ });
@@ -0,0 +1,49 @@
1
+ import { useRef } from 'react';
2
+
3
+ /**
4
+ * Compares two objects and returns true if they are equal, false otherwise.
5
+ * @param objA The first object to compare.
6
+ * @param objB The second object to compare.
7
+ * @returns
8
+ */
9
+ function shallowEqual<T>(objA: T, objB: T): boolean {
10
+ if (Object.is(objA, objB)) {
11
+ return true;
12
+ }
13
+
14
+ if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
15
+ return false;
16
+ }
17
+
18
+ const keysA = Object.keys(objA) as Array<keyof T>;
19
+
20
+ const keysB = Object.keys(objB) as Array<keyof T>;
21
+
22
+ if (keysA.length !== keysB.length) {
23
+ return false;
24
+ }
25
+
26
+ for (const key of keysA) {
27
+ if (!Object.prototype.hasOwnProperty.call(objB, key) || !Object.is(objA[key], objB[key])) {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ return true;
33
+ }
34
+
35
+ /**
36
+ * Memoizes an object and returns the memoized object.
37
+ * Mantaing the same reference if the object is the same independently if is called inside any component.
38
+ * @param object
39
+ * @returns
40
+ */
41
+ export function useMemoizedObject<T extends object>(object: T): T {
42
+ const ref = useRef<T>(object);
43
+
44
+ if (!shallowEqual(ref.current, object)) {
45
+ ref.current = object;
46
+ }
47
+
48
+ return ref.current;
49
+ }
@@ -0,0 +1,142 @@
1
+ import {
2
+ checkFlagExperimentAlreadyChecked,
3
+ getDataExperimentAttributes,
4
+ getExperimentScriptTag,
5
+ getFullUrl,
6
+ getScriptDataAttributes
7
+ } from './utils';
8
+
9
+ import { EXPERIMENT_ALREADY_CHECKED_KEY, EXPERIMENT_SCRIPT_FILE_NAME } from '../constants';
10
+ import { LocationMock } from '../mocks/mock';
11
+
12
+ describe('Utility ', () => {
13
+ describe('getExperimentScriptTag', () => {
14
+ it('should throw an error if the experiment script is not found', () => {
15
+ document.body.innerHTML = `<script src="other-script.js"></script>`;
16
+ expect(() => getExperimentScriptTag()).toThrow('Experiment script not found');
17
+ });
18
+
19
+ it('should return the script element when the experiment script is found', () => {
20
+ const experimentScriptUrl = 'http://example.com/' + EXPERIMENT_SCRIPT_FILE_NAME;
21
+
22
+ document.body.innerHTML = `<script src="${experimentScriptUrl}"></script>`;
23
+
24
+ const scriptTag = getExperimentScriptTag();
25
+
26
+ expect(scriptTag).toBeDefined();
27
+ expect(scriptTag.src).toBe(experimentScriptUrl);
28
+ });
29
+ });
30
+
31
+ describe('getDataExperimentAttributes', () => {
32
+ const location: Location = { ...LocationMock, href: 'http:/localhost/' };
33
+
34
+ it('should return null and warn if data-experiment-api-key is not specified but script is present', () => {
35
+ const experimentScriptUrl = 'http://example.com/' + EXPERIMENT_SCRIPT_FILE_NAME;
36
+
37
+ document.body.innerHTML = `<script src="${experimentScriptUrl}"></script>`;
38
+
39
+ try {
40
+ getDataExperimentAttributes(location);
41
+ expect('This should not be reached if an error is thrown').toBeNull();
42
+ } catch (error) {
43
+ expect(() => getDataExperimentAttributes(location)).toThrow(
44
+ 'You need specify the `data-experiment-api-key`'
45
+ );
46
+ }
47
+ });
48
+
49
+ it('should return the experiment attributes if they are present', () => {
50
+ const experimentScriptUrl = 'http://example.com/' + EXPERIMENT_SCRIPT_FILE_NAME;
51
+
52
+ document.body.innerHTML = `<script src="${experimentScriptUrl}" data-experiment-api-key="testKey" data-experiment-server="http://localhost"></script>`;
53
+
54
+ const attributes = getDataExperimentAttributes(location);
55
+
56
+ expect(attributes).toEqual({
57
+ apiKey: 'testKey',
58
+ server: 'http://localhost',
59
+ debug: false
60
+ });
61
+ });
62
+ });
63
+
64
+ describe('getScriptDataAttributes', () => {
65
+ it('should return the experiment attributes if they are present', () => {
66
+ const experimentScriptUrl = 'http://example.com/' + EXPERIMENT_SCRIPT_FILE_NAME;
67
+
68
+ document.body.innerHTML = `<script src="${experimentScriptUrl}" data-experiment-api-key="testKey" data-experiment-server="http://localhost"></script>`;
69
+
70
+ // eslint-disable-next-line no-restricted-globals
71
+ const attributes = getScriptDataAttributes(location);
72
+
73
+ expect(attributes).toEqual({
74
+ apiKey: 'testKey',
75
+ server: 'http://localhost',
76
+ debug: false
77
+ });
78
+ });
79
+ });
80
+
81
+ describe('SessionStorage EXPERIMENT_ALREADY_CHECKED_KEY handle', () => {
82
+ Object.defineProperty(window, 'sessionStorage', {
83
+ value: {
84
+ setItem: jest.fn(),
85
+ getItem: jest.fn()
86
+ },
87
+ writable: true
88
+ });
89
+
90
+ describe('checkFlagExperimentAlreadyChecked', () => {
91
+ const getItemMock = window.sessionStorage.getItem as jest.MockedFunction<
92
+ typeof window.sessionStorage.getItem
93
+ >;
94
+
95
+ const testCases = [
96
+ { value: '', expected: false, description: 'sessionStorage value is ""' },
97
+ { value: 'true', expected: true, description: 'sessionStorage value is "true"' },
98
+ { value: null, expected: false, description: 'sessionStorage value is null' }
99
+ ];
100
+
101
+ testCases.forEach(({ description, value, expected }) => {
102
+ it(`returns ${expected} when ${description}`, () => {
103
+ getItemMock.mockReturnValue(value);
104
+
105
+ expect(checkFlagExperimentAlreadyChecked()).toBe(expected);
106
+ expect(getItemMock).toHaveBeenCalledWith(EXPERIMENT_ALREADY_CHECKED_KEY);
107
+ });
108
+ });
109
+ });
110
+ });
111
+
112
+ describe('getFullUrl', () => {
113
+ const href = 'http://localhost/';
114
+
115
+ const location: Location = { ...LocationMock, href };
116
+
117
+ it('should return null if absolutePath is null', () => {
118
+ const result = getFullUrl(location, null);
119
+
120
+ expect(result).toBeNull();
121
+ });
122
+
123
+ it('should return the same absolutePath if it is a full URL', () => {
124
+ const absolutePath = href + '/test';
125
+
126
+ const expectedUrl = absolutePath;
127
+
128
+ const result = getFullUrl(location, absolutePath);
129
+
130
+ expect(result).toBe(absolutePath);
131
+ expect(result).toBe(expectedUrl);
132
+ });
133
+
134
+ it('should return a full URL if absolutePath is a relative path', () => {
135
+ const absolutePath = '/test';
136
+
137
+ const result = getFullUrl(location, absolutePath);
138
+
139
+ expect(result).toBe(`${location.origin}${absolutePath}`);
140
+ });
141
+ });
142
+ });