@treely/strapi-slices 7.16.1 → 7.17.0
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.
- package/dist/strapi-slices.cjs.development.js +31 -3
- package/dist/strapi-slices.cjs.development.js.map +1 -1
- package/dist/strapi-slices.cjs.production.min.js +1 -1
- package/dist/strapi-slices.cjs.production.min.js.map +1 -1
- package/dist/strapi-slices.esm.js +31 -3
- package/dist/strapi-slices.esm.js.map +1 -1
- package/dist/utils/buildRedirectUrl.d.ts +3 -0
- package/package.json +2 -2
- package/src/slices/Redirect/Redirect.test.tsx +73 -0
- package/src/slices/Redirect/Redirect.tsx +14 -3
- package/src/utils/buildRedirectUrl.test.ts +102 -0
- package/src/utils/buildRedirectUrl.ts +34 -0
- package/src/slices/Redirect/Rediect.test.tsx +0 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@treely/strapi-slices",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.17.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Tree.ly FlexCo",
|
|
6
6
|
"description": "@treely/strapi-slices is a open source library maintained by Tree.ly.",
|
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
"mq-polyfill": "^1.1.8",
|
|
137
137
|
"react": "^18.2.0",
|
|
138
138
|
"react-dom": "^18.2.0",
|
|
139
|
-
"semantic-release": "^
|
|
139
|
+
"semantic-release": "^25.0.1",
|
|
140
140
|
"size-limit": "^11.0.0",
|
|
141
141
|
"storybook": "^8.6.4",
|
|
142
142
|
"typescript": "^5.3.2"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '../../test/testUtils';
|
|
3
|
+
import { mergeDeep } from '../../utils/mergeDeep';
|
|
4
|
+
import { useRouter, replaceSpy } from '../../../__mocks__/next/router';
|
|
5
|
+
import { DEFAULT_USE_ROUTER_RETURN_VALUE } from '../../test/defaultMocks/next';
|
|
6
|
+
import Redirect from '.';
|
|
7
|
+
import type { RedirectProps } from './Redirect';
|
|
8
|
+
|
|
9
|
+
const defaultProps: RedirectProps = {
|
|
10
|
+
slice: { url: 'https://redirect.com' },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const setup = (
|
|
14
|
+
props: Partial<RedirectProps> = {},
|
|
15
|
+
routerOverrides: any = {}
|
|
16
|
+
) => {
|
|
17
|
+
const combinedProps = mergeDeep(defaultProps, props);
|
|
18
|
+
useRouter.mockReturnValue({
|
|
19
|
+
...DEFAULT_USE_ROUTER_RETURN_VALUE,
|
|
20
|
+
asPath: '/from',
|
|
21
|
+
query: { utm_source: 'linkedin' },
|
|
22
|
+
replace: replaceSpy,
|
|
23
|
+
...routerOverrides,
|
|
24
|
+
});
|
|
25
|
+
render(<Redirect {...combinedProps} />);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('Redirect component', () => {
|
|
29
|
+
const originalDateNow = Date.now;
|
|
30
|
+
const originalLocation = window.location;
|
|
31
|
+
const mockDateNow = jest.fn(() => 1730284800000);
|
|
32
|
+
const mockOrigin = 'https://origin.com';
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
Date.now = mockDateNow;
|
|
36
|
+
// @ts-ignore - Mocking window.location
|
|
37
|
+
Object.defineProperty(window, 'location', {
|
|
38
|
+
value: { ...originalLocation, origin: mockOrigin },
|
|
39
|
+
writable: true,
|
|
40
|
+
});
|
|
41
|
+
jest.clearAllMocks();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
Date.now = originalDateNow;
|
|
46
|
+
Object.defineProperty(window, 'location', {
|
|
47
|
+
value: originalLocation,
|
|
48
|
+
writable: true,
|
|
49
|
+
});
|
|
50
|
+
useRouter.mockRestore();
|
|
51
|
+
replaceSpy.mockRestore();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('calls the redirect URL when rendering preserving the utm_* params and passing the source and ts params', () => {
|
|
55
|
+
setup();
|
|
56
|
+
|
|
57
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
58
|
+
const calledUrl = replaceSpy.mock.calls[0][0];
|
|
59
|
+
const url = new URL(calledUrl);
|
|
60
|
+
|
|
61
|
+
// URLSearchParams.get automatically decodes values
|
|
62
|
+
expect(url.searchParams.get('source')).toBe('https://origin.com/from');
|
|
63
|
+
expect(url.searchParams.get('utm_source')).toBe('linkedin');
|
|
64
|
+
expect(url.searchParams.get('ts')).toBe('1730284800000');
|
|
65
|
+
expect(url.origin + url.pathname).toBe('https://redirect.com/');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does nothing when slice.url is empty', () => {
|
|
69
|
+
setup({ slice: { url: '' } });
|
|
70
|
+
|
|
71
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useRouter } from 'next/router';
|
|
2
2
|
import React, { useEffect } from 'react';
|
|
3
|
+
import { buildRedirectUrl } from '../../utils/buildRedirectUrl';
|
|
3
4
|
|
|
4
5
|
export interface RedirectProps {
|
|
5
6
|
slice: {
|
|
@@ -9,10 +10,20 @@ export interface RedirectProps {
|
|
|
9
10
|
|
|
10
11
|
export const Redirect = ({ slice }: RedirectProps): JSX.Element => {
|
|
11
12
|
const router = useRouter();
|
|
13
|
+
|
|
12
14
|
useEffect(() => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
if (!slice.url) return;
|
|
16
|
+
|
|
17
|
+
// Build redirect URL
|
|
18
|
+
const redirectUrl = buildRedirectUrl(
|
|
19
|
+
slice.url,
|
|
20
|
+
router.asPath,
|
|
21
|
+
router.query
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// Redirect
|
|
25
|
+
router.replace(redirectUrl);
|
|
26
|
+
}, [slice.url, router]);
|
|
16
27
|
|
|
17
28
|
return <></>;
|
|
18
29
|
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { buildRedirectUrl } from './buildRedirectUrl';
|
|
2
|
+
|
|
3
|
+
const ORIGIN = 'https://site.local';
|
|
4
|
+
|
|
5
|
+
const getParams = (u: string) => new URL(u).searchParams;
|
|
6
|
+
|
|
7
|
+
describe('buildRedirectUrl', () => {
|
|
8
|
+
let dateNowSpy: jest.SpyInstance<number, []>;
|
|
9
|
+
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
// stable origin for all tests
|
|
12
|
+
Object.defineProperty(window, 'location', {
|
|
13
|
+
value: { ...window.location, origin: ORIGIN },
|
|
14
|
+
writable: true,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
// stable timestamp per test
|
|
20
|
+
dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1730284800000);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
dateNowSpy.mockRestore();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns empty string for empty target url', () => {
|
|
28
|
+
expect(buildRedirectUrl('', '/from', {})).toBe('');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('adds absolute source and ts, preserves existing target params', () => {
|
|
32
|
+
const out = buildRedirectUrl(
|
|
33
|
+
'https://redirect.com/landing?existing=value',
|
|
34
|
+
'/from',
|
|
35
|
+
{}
|
|
36
|
+
);
|
|
37
|
+
const p = getParams(out);
|
|
38
|
+
expect(p.get('existing')).toBe('value');
|
|
39
|
+
expect(p.get('source')).toBe(`${ORIGIN}/from`);
|
|
40
|
+
expect(p.get('ts')).toBe('1730284800000');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('forwards utm_* params from current page and ignores others', () => {
|
|
44
|
+
const out = buildRedirectUrl('https://redirect.com', '/page', {
|
|
45
|
+
utm_source: 'google',
|
|
46
|
+
utm_medium: 'cpc',
|
|
47
|
+
utm_campaign: 'spring',
|
|
48
|
+
other: 'ignore-me',
|
|
49
|
+
});
|
|
50
|
+
const p = getParams(out);
|
|
51
|
+
expect(p.get('utm_source')).toBe('google');
|
|
52
|
+
expect(p.get('utm_medium')).toBe('cpc');
|
|
53
|
+
expect(p.get('utm_campaign')).toBe('spring');
|
|
54
|
+
expect(p.get('other')).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('includes query string in source when asPath has one', () => {
|
|
58
|
+
const out = buildRedirectUrl(
|
|
59
|
+
'https://redirect.com',
|
|
60
|
+
'/from?utm_source=fb&x=1',
|
|
61
|
+
{ utm_source: 'fb' }
|
|
62
|
+
);
|
|
63
|
+
const p = getParams(out);
|
|
64
|
+
expect(p.get('source')).toBe(`${ORIGIN}/from?utm_source=fb&x=1`);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('handles relative target URLs using window.location.origin', () => {
|
|
68
|
+
const out = buildRedirectUrl('/relative', '/current', {});
|
|
69
|
+
expect(out.startsWith(`${ORIGIN}/relative?`)).toBe(true);
|
|
70
|
+
const p = getParams(out);
|
|
71
|
+
expect(p.get('source')).toBe(`${ORIGIN}/current`);
|
|
72
|
+
expect(p.get('ts')).toBe('1730284800000');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('overwrites existing source in target (deduplicated)', () => {
|
|
76
|
+
const out = buildRedirectUrl('https://redirect.com?source=old', '/new', {});
|
|
77
|
+
const p = getParams(out);
|
|
78
|
+
expect(p.get('source')).toBe(`${ORIGIN}/new`);
|
|
79
|
+
expect(p.getAll('source').length).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('skips array values from router.query', () => {
|
|
83
|
+
const out = buildRedirectUrl('https://redirect.com', '/x', {
|
|
84
|
+
utm_source: ['a', 'b'] as any,
|
|
85
|
+
utm_medium: ['m'] as any,
|
|
86
|
+
});
|
|
87
|
+
const p = getParams(out);
|
|
88
|
+
expect(p.get('utm_source')).toBeNull();
|
|
89
|
+
expect(p.get('utm_medium')).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('does not add utm params when none present', () => {
|
|
93
|
+
const out = buildRedirectUrl('https://redirect.com', '/from', {
|
|
94
|
+
foo: 'bar',
|
|
95
|
+
});
|
|
96
|
+
const p = getParams(out);
|
|
97
|
+
expect(p.get('utm_source')).toBeNull();
|
|
98
|
+
expect(p.get('utm_medium')).toBeNull();
|
|
99
|
+
expect(p.get('utm_campaign')).toBeNull();
|
|
100
|
+
expect(p.get('source')).toBe(`${ORIGIN}/from`);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ParsedUrlQuery } from 'querystring';
|
|
2
|
+
|
|
3
|
+
export function buildRedirectUrl(
|
|
4
|
+
url: string,
|
|
5
|
+
asPath: string,
|
|
6
|
+
query: ParsedUrlQuery
|
|
7
|
+
): string {
|
|
8
|
+
if (!url) return '';
|
|
9
|
+
|
|
10
|
+
// Parse the base target URL
|
|
11
|
+
const target = new URL(url, window.location.origin);
|
|
12
|
+
|
|
13
|
+
// Merge existing params from the target
|
|
14
|
+
const mergedParams = new URLSearchParams(target.search);
|
|
15
|
+
|
|
16
|
+
// Add absolute source
|
|
17
|
+
const absoluteSource = `${window.location.origin}${asPath}`;
|
|
18
|
+
mergedParams.set('source', absoluteSource);
|
|
19
|
+
|
|
20
|
+
// Forward utm_* params from the current page
|
|
21
|
+
for (const [key, value] of Object.entries(query)) {
|
|
22
|
+
if (key.startsWith('utm_') && typeof value === 'string') {
|
|
23
|
+
mergedParams.set(key, value);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Add timestamp
|
|
28
|
+
mergedParams.set('ts', Date.now().toString());
|
|
29
|
+
|
|
30
|
+
// Build final merged URL
|
|
31
|
+
target.search = mergedParams.toString();
|
|
32
|
+
|
|
33
|
+
return target.toString();
|
|
34
|
+
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { render } from '../../test/testUtils';
|
|
3
|
-
import { mergeDeep } from '../../utils/mergeDeep';
|
|
4
|
-
import { replaceSpy, useRouter } from '../../../__mocks__/next/router';
|
|
5
|
-
import Redirect from '.';
|
|
6
|
-
import { RedirectProps } from './Redirect';
|
|
7
|
-
|
|
8
|
-
const defaultProps: RedirectProps = {
|
|
9
|
-
slice: {
|
|
10
|
-
url: 'https://redirect.com',
|
|
11
|
-
},
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const setup = (props = {}) => {
|
|
15
|
-
const combinedProps = mergeDeep(defaultProps, props);
|
|
16
|
-
render(<Redirect {...combinedProps} />);
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
describe('The Redirect component', () => {
|
|
20
|
-
afterEach(() => {
|
|
21
|
-
replaceSpy.mockRestore();
|
|
22
|
-
useRouter.mockRestore();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('calls the redirect URL when rendering', () => {
|
|
26
|
-
setup();
|
|
27
|
-
|
|
28
|
-
expect(replaceSpy).toHaveBeenCalledWith('https://redirect.com');
|
|
29
|
-
});
|
|
30
|
-
});
|