@capillarytech/creatives-library 9.0.36 → 9.0.37-alpha.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/AppRoot.js +124 -0
- package/app-config.js +61 -0
- package/app.js +10 -175
- package/bootstrap.js +185 -0
- package/entry.js +67 -1
- package/mfe-exposed-components.js +2 -4
- package/package.json +2 -2
- package/services/api.js +9 -1
- package/styles/containers/layout/_layoutPage.scss +8 -6
- package/translations/en.json +2 -2
- package/utils/getDataLayer.js +15 -0
- package/utils/gtmTrackers/gtmEvents/creativeDetails.js +2 -1
- package/utils/mfeDetect.js +1 -0
- package/utils/mfeFirstPaintReady.js +29 -0
- package/utils/mfeHistory.js +62 -0
- package/utils/permissions.js +59 -0
- package/utils/rcsPayloadUtils.js +5 -2
- package/utils/tests/permissions.test.js +116 -0
- package/utils/tests/rcsPayloadUtils.test.js +54 -2
- package/v2Components/AccessForbidden/_accessForbidden.scss +14 -0
- package/v2Components/AccessForbidden/index.js +5 -3
- package/v2Components/AccessForbidden/messages.js +3 -2
- package/v2Components/CapActionButton/constants.js +1 -0
- package/v2Components/CapActionButton/index.js +77 -9
- package/v2Components/CapActionButton/index.scss +13 -0
- package/v2Components/CapActionButton/messages.js +13 -0
- package/v2Components/CapActionButton/tests/index.test.js +32 -1
- package/v2Components/CommonTestAndPreview/index.js +44 -15
- package/v2Components/CommonTestAndPreview/tests/index.test.js +78 -0
- package/v2Components/CommonTestAndPreview/utils.js +34 -0
- package/v2Components/NavigationBar/index.js +9 -7
- package/v2Components/NavigationBar/mfeModuleHeader.config.js +16 -0
- package/v2Components/NavigationBar/tests/index.test.js +39 -19
- package/v2Components/NoPermissionWrapper/_noPermissionWrapper.scss +12 -0
- package/v2Components/NoPermissionWrapper/index.js +26 -0
- package/v2Components/SmsFallback/index.js +6 -0
- package/v2Containers/App/constants.js +5 -0
- package/v2Containers/Assets/Gallery/_gallery.scss +6 -0
- package/v2Containers/Assets/Gallery/index.js +34 -10
- package/v2Containers/Cap/constants.js +1 -0
- package/v2Containers/Cap/index.js +62 -29
- package/v2Containers/Cap/messages.js +4 -0
- package/v2Containers/Cap/tests/__snapshots__/index.test.js.snap +2 -2
- package/v2Containers/CreativesContainer/index.js +11 -8
- package/v2Containers/CreativesContainer/tests/index.test.js +53 -0
- package/v2Containers/Line/Container/ImageCarousel/tests/__snapshots__/content.test.js.snap +6 -6
- package/v2Containers/Line/Container/ImageCarousel/tests/__snapshots__/index.test.js.snap +4 -4
- package/v2Containers/Line/Container/Wrapper/tests/__snapshots__/index.test.js.snap +32 -32
- package/v2Containers/Line/Container/tests/__snapshots__/index.test.js.snap +36 -36
- package/v2Containers/Rcs/components/CarouselCard.js +5 -0
- package/v2Containers/Rcs/components/CarouselCardButtons.js +20 -0
- package/v2Containers/Rcs/index.js +154 -79
- package/v2Containers/Rcs/rcsLibraryHydrationUtils.js +55 -4
- package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +404 -118
- package/v2Containers/Rcs/tests/carouselUtils.test.js +13 -17
- package/v2Containers/Rcs/tests/index.test.js +141 -6
- package/v2Containers/Rcs/tests/rcsLibraryHydrationUtils.test.js +110 -0
- package/v2Containers/Rcs/tests/utils.test.js +36 -0
- package/v2Containers/Rcs/utils.js +16 -4
- package/v2Containers/SmsTrai/Create/tests/__snapshots__/index.test.js.snap +8 -8
- package/v2Containers/SmsTrai/Edit/index.js +35 -24
- package/v2Containers/SmsTrai/Edit/tests/__snapshots__/index.test.js.snap +18 -18
- package/v2Containers/Templates/ChannelTypeIllustration.js +10 -1
- package/v2Containers/Templates/_templates.scss +7 -1
- package/v2Containers/Templates/index.js +141 -99
- package/v2Containers/Templates/tests/ChannelTypeIllustration.test.js +7 -0
- package/v2Containers/Templates/tests/__snapshots__/index.test.js.snap +173 -115
- package/v2Containers/TemplatesV2/TemplatesV2.style.js +8 -3
- package/v2Containers/TemplatesV2/index.js +38 -14
- package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +268 -268
|
@@ -79,7 +79,6 @@
|
|
|
79
79
|
flex: 1;
|
|
80
80
|
height: calc(100vh - 50px);
|
|
81
81
|
overflow: auto;
|
|
82
|
-
padding: 8px 16px 0;
|
|
83
82
|
color: #333333;
|
|
84
83
|
}
|
|
85
84
|
|
|
@@ -154,16 +153,19 @@
|
|
|
154
153
|
}
|
|
155
154
|
|
|
156
155
|
.cap-loader-box {
|
|
157
|
-
position:
|
|
156
|
+
position: absolute;
|
|
158
157
|
top: 0;
|
|
158
|
+
left: 0;
|
|
159
159
|
width: 100%;
|
|
160
|
-
height:
|
|
161
|
-
background:
|
|
162
|
-
|
|
160
|
+
min-height: calc(100vh - 48px);
|
|
161
|
+
background: #ffffff;
|
|
162
|
+
display: flex;
|
|
163
|
+
align-items: center;
|
|
164
|
+
justify-content: center;
|
|
163
165
|
|
|
164
166
|
.loader-image {
|
|
165
167
|
width: 80px;
|
|
166
|
-
|
|
168
|
+
height: auto;
|
|
167
169
|
}
|
|
168
170
|
}
|
|
169
171
|
|
package/translations/en.json
CHANGED
|
@@ -63,8 +63,8 @@
|
|
|
63
63
|
"app.v2containers.SmsTrai.Edit.traiEditTitle": "Message",
|
|
64
64
|
"app.v2containers.SmsTrai.Edit.unicodeLabel": "Allow unicode characters",
|
|
65
65
|
"app.v2containers.SmsTrai.Edit.unsupportedTagsValidationError": "Unsupported tags: {unsupportedTags}. Please remove them from this message.",
|
|
66
|
-
"creatives.components.AccessForbidden.forbiddenDesc": "
|
|
67
|
-
"creatives.components.AccessForbidden.forbiddenHeader": "
|
|
66
|
+
"creatives.components.AccessForbidden.forbiddenDesc": "Please contact your organisation administrator to request access.",
|
|
67
|
+
"creatives.components.AccessForbidden.forbiddenHeader": "You don't have permission to view this page.",
|
|
68
68
|
"creatives.components.BreadCrumbs.header": "This is the BreadCrumbs component !",
|
|
69
69
|
"creatives.components.CapTagList.Cancel": "Cancel",
|
|
70
70
|
"creatives.components.CapTagList.Ok": "Ok",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { isMFEMode } from './mfeDetect';
|
|
2
|
+
import appConfig from '../app-config';
|
|
3
|
+
|
|
4
|
+
const getDataLayerName = () => {
|
|
5
|
+
if (isMFEMode()) {
|
|
6
|
+
return `dataLayer_${appConfig.appName.replace(/-/g, '_')}`;
|
|
7
|
+
}
|
|
8
|
+
return 'dataLayer';
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const getDataLayer = () => {
|
|
12
|
+
const name = getDataLayerName();
|
|
13
|
+
window[name] = window[name] || [];
|
|
14
|
+
return window[name];
|
|
15
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CREATIVES } from "../../../v2Containers/App/constants";
|
|
2
|
+
import { getDataLayer } from '../../getDataLayer';
|
|
2
3
|
|
|
3
4
|
const creativeDetails = ({
|
|
4
5
|
name,
|
|
@@ -23,7 +24,7 @@ const creativeDetails = ({
|
|
|
23
24
|
videoAdded,
|
|
24
25
|
stickersAdded
|
|
25
26
|
};
|
|
26
|
-
|
|
27
|
+
getDataLayer().push({
|
|
27
28
|
creativeDetails: parsedObj,
|
|
28
29
|
event: 'creativeDetails',
|
|
29
30
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const isMFEMode = () => Boolean(window.MFE_HOST);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { MFEEventBus } from '@capillarytech/cap-ui-utils';
|
|
2
|
+
import appConfig from '../app-config';
|
|
3
|
+
import { isMFEMode } from './mfeDetect';
|
|
4
|
+
|
|
5
|
+
// Routes whose containers emit the data-settled 'lcp' themselves —
|
|
6
|
+
// the first-paint emit must NOT fire for these.
|
|
7
|
+
// TemplatesV2 renders at relative '/v2' and '/v2/loyalty' (routes.js); in MFE mode
|
|
8
|
+
// the shared history is rebased on publicPath, so the real browser pathname carries
|
|
9
|
+
// the '/creatives/ui' prefix. Match the prefixed paths — this util reads
|
|
10
|
+
// window.location.pathname, not the rebased router location. (v1 standalone is
|
|
11
|
+
// non-MFE and irrelevant here.)
|
|
12
|
+
const DATA_LANDING_MATCHERS = ['/creatives/ui/v2', '/creatives/ui/v2/loyalty'];
|
|
13
|
+
|
|
14
|
+
export const isDataLandingPath = (pathname) => {
|
|
15
|
+
const p = (pathname || '').replace(/\/+$/, '') || '/';
|
|
16
|
+
return DATA_LANDING_MATCHERS.some((m) => (m instanceof RegExp ? m.test(p) : m === p));
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const emitFirstPaintReadyIfNotDataLanding = () => {
|
|
20
|
+
if (!isMFEMode() || isDataLandingPath(window.location.pathname)) return;
|
|
21
|
+
requestAnimationFrame(() => {
|
|
22
|
+
MFEEventBus.emit('mfe:segment', {
|
|
23
|
+
action: 'stop',
|
|
24
|
+
appId: appConfig.appName,
|
|
25
|
+
type: 'lcp',
|
|
26
|
+
timestamp: performance.now(),
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-base the host's shared root history for this MFE remote.
|
|
3
|
+
*
|
|
4
|
+
* The MFE host owns ONE root history instance and hands it to every remote, so host + all
|
|
5
|
+
* remotes stay perfectly in sync: a push on the shared instance notifies every mounted
|
|
6
|
+
* router directly (no popstate hack, no drift on fast remote switching).
|
|
7
|
+
*
|
|
8
|
+
* This remote's routes / <Link>s / history.push calls are written RELATIVE to its serving
|
|
9
|
+
* prefix. This adapter presents a basename'd VIEW of the shared history — stripping the
|
|
10
|
+
* prefix when the router reads the location, and prepending it when the router navigates —
|
|
11
|
+
* while delegating all actual navigation to the shared instance. Net effect: relative
|
|
12
|
+
* routing keeps working unchanged, and navigation still flows through the single shared
|
|
13
|
+
* history. Standalone (no host history) is unaffected; the remote keeps creating its own.
|
|
14
|
+
*/
|
|
15
|
+
const addBase = (base, to) => {
|
|
16
|
+
if (typeof to === 'string') {
|
|
17
|
+
if (!to.startsWith('/')) return to; // already relative to current location
|
|
18
|
+
return to === '/' ? base : `${base}${to}`;
|
|
19
|
+
}
|
|
20
|
+
if (to && typeof to === 'object') {
|
|
21
|
+
return { ...to, pathname: addBase(base, to.pathname || '/') };
|
|
22
|
+
}
|
|
23
|
+
return to;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const stripBase = (base, pathname) => {
|
|
27
|
+
if (!pathname) return pathname;
|
|
28
|
+
if (pathname === base) return '/';
|
|
29
|
+
if (pathname.startsWith(`${base}/`)) return pathname.slice(base.length);
|
|
30
|
+
return pathname;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default function rebaseHistory(history, rawBase) {
|
|
34
|
+
const base = String(rawBase || '').replace(/\/+$/, ''); // normalise: no trailing slash
|
|
35
|
+
if (!base) return history;
|
|
36
|
+
const view = loc => ({ ...loc, pathname: stripBase(base, loc.pathname) });
|
|
37
|
+
return {
|
|
38
|
+
get length() {
|
|
39
|
+
return history.length;
|
|
40
|
+
},
|
|
41
|
+
get action() {
|
|
42
|
+
return history.action;
|
|
43
|
+
},
|
|
44
|
+
get location() {
|
|
45
|
+
return view(history.location);
|
|
46
|
+
},
|
|
47
|
+
push: (to, state) => history.push(addBase(base, to), state),
|
|
48
|
+
replace: (to, state) => history.replace(addBase(base, to), state),
|
|
49
|
+
go: n => history.go(n),
|
|
50
|
+
goBack: () => history.goBack(),
|
|
51
|
+
goForward: () => history.goForward(),
|
|
52
|
+
block: (...args) => history.block(...args),
|
|
53
|
+
listen: listener =>
|
|
54
|
+
history.listen((loc, action) => listener(view(loc), action)),
|
|
55
|
+
createHref: to =>
|
|
56
|
+
history.createHref(
|
|
57
|
+
typeof to === 'string'
|
|
58
|
+
? addBase(base, to)
|
|
59
|
+
: { ...to, pathname: addBase(base, (to && to.pathname) || '/') },
|
|
60
|
+
),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CREATIVE_VIEW,
|
|
3
|
+
CREATIVE_CREATE,
|
|
4
|
+
CREATIVE_EDIT,
|
|
5
|
+
CREATIVE_DELETE,
|
|
6
|
+
CREATIVES_UI_VIEW,
|
|
7
|
+
} from '../v2Containers/App/constants';
|
|
8
|
+
|
|
9
|
+
export const PERMISSIONS = {
|
|
10
|
+
CREATIVE_VIEW,
|
|
11
|
+
CREATIVE_CREATE,
|
|
12
|
+
CREATIVE_EDIT,
|
|
13
|
+
CREATIVE_DELETE,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const PERMISSION_IDS = {
|
|
17
|
+
[CREATIVE_VIEW]: 1508,
|
|
18
|
+
[CREATIVE_CREATE]: 1509,
|
|
19
|
+
[CREATIVE_EDIT]: 1510,
|
|
20
|
+
[CREATIVE_DELETE]: 1511,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Mode-aware access resolver for the tri-modal Creatives app.
|
|
25
|
+
* Standalone: the `cap` slice is passed as a prop by the connected container.
|
|
26
|
+
* Library / MFE: the host passes a `cap` (or legacy `Global`) prop.
|
|
27
|
+
* Reads the prop first, then falls back — mirrors TemplatesV2's existing
|
|
28
|
+
* `cap.user || Global.user` resolution.
|
|
29
|
+
*/
|
|
30
|
+
export const getUserAccess = (props = {}) => {
|
|
31
|
+
const { cap = {}, Global = {} } = props;
|
|
32
|
+
const user = (cap && cap.user) || (Global && Global.user) || {};
|
|
33
|
+
const isLoaded = !!(
|
|
34
|
+
user &&
|
|
35
|
+
typeof user === 'object' &&
|
|
36
|
+
Object.keys(user).length > 0
|
|
37
|
+
);
|
|
38
|
+
return {
|
|
39
|
+
accessiblePermissions: (isLoaded && user.accessiblePermissions) || [],
|
|
40
|
+
isLoaded,
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const hasPermission = (userAccess, code) => {
|
|
45
|
+
if (!userAccess) return false;
|
|
46
|
+
const granted = userAccess.accessiblePermissions || [];
|
|
47
|
+
const id = PERMISSION_IDS[code];
|
|
48
|
+
return (
|
|
49
|
+
granted.includes(code) ||
|
|
50
|
+
granted.includes(id) ||
|
|
51
|
+
granted.includes(String(id))
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Zero-impact view gate: honour the new CREATIVE_VIEW OR the legacy CREATIVES_UI_VIEW.
|
|
56
|
+
export const canViewCreatives = userAccess =>
|
|
57
|
+
hasPermission(userAccess, CREATIVE_VIEW) ||
|
|
58
|
+
(userAccess &&
|
|
59
|
+
(userAccess.accessiblePermissions || []).includes(CREATIVES_UI_VIEW));
|
package/utils/rcsPayloadUtils.js
CHANGED
|
@@ -59,9 +59,12 @@ export const normalizeRcsMessageContentForApi = messageContentItem => {
|
|
|
59
59
|
rcsCardPayloadOnly.cardContent.forEach(card => {
|
|
60
60
|
if (Array.isArray(card?.suggestions)) {
|
|
61
61
|
card.suggestions.forEach(suggestion => {
|
|
62
|
-
// isSaved
|
|
63
|
-
//
|
|
62
|
+
// isSaved and urlType are UI-only editor state: campaigns' send-for-approval API
|
|
63
|
+
// rejects them, and they aren't part of the persisted payload shape. The dynamic tag
|
|
64
|
+
// (if any) already lives inline in `url` (e.g. "https://x.com{{first_name}}") — no
|
|
65
|
+
// separate dynamicUrlPayload field is sent for RCS.
|
|
64
66
|
delete suggestion?.isSaved;
|
|
67
|
+
delete suggestion?.urlType;
|
|
65
68
|
});
|
|
66
69
|
}
|
|
67
70
|
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test suite for RBAC permission helpers (app/utils/permissions.js).
|
|
3
|
+
* Covers getUserAccess (loaded/unloaded, cap vs Global, missing perms),
|
|
4
|
+
* hasPermission (null guard, code/id/string-id match, empty), and
|
|
5
|
+
* canViewCreatives (new perm, legacy perm, null guard).
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
PERMISSIONS,
|
|
9
|
+
PERMISSION_IDS,
|
|
10
|
+
getUserAccess,
|
|
11
|
+
hasPermission,
|
|
12
|
+
canViewCreatives,
|
|
13
|
+
} from '../permissions';
|
|
14
|
+
import {
|
|
15
|
+
CREATIVE_VIEW,
|
|
16
|
+
CREATIVE_CREATE,
|
|
17
|
+
CREATIVE_EDIT,
|
|
18
|
+
CREATIVE_DELETE,
|
|
19
|
+
CREATIVES_UI_VIEW,
|
|
20
|
+
} from '../../v2Containers/App/constants';
|
|
21
|
+
|
|
22
|
+
describe('permissions - exported maps', () => {
|
|
23
|
+
it('exposes the four creative permission codes', () => {
|
|
24
|
+
expect(PERMISSIONS).toEqual({
|
|
25
|
+
CREATIVE_VIEW,
|
|
26
|
+
CREATIVE_CREATE,
|
|
27
|
+
CREATIVE_EDIT,
|
|
28
|
+
CREATIVE_DELETE,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('maps each permission code to its id', () => {
|
|
33
|
+
expect(PERMISSION_IDS[CREATIVE_VIEW]).toBe(1508);
|
|
34
|
+
expect(PERMISSION_IDS[CREATIVE_CREATE]).toBe(1509);
|
|
35
|
+
expect(PERMISSION_IDS[CREATIVE_EDIT]).toBe(1510);
|
|
36
|
+
expect(PERMISSION_IDS[CREATIVE_DELETE]).toBe(1511);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('getUserAccess', () => {
|
|
41
|
+
it('returns unloaded access with empty perms when no props are passed', () => {
|
|
42
|
+
expect(getUserAccess()).toEqual({ accessiblePermissions: [], isLoaded: false });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns unloaded access for an empty props object', () => {
|
|
46
|
+
expect(getUserAccess({})).toEqual({ accessiblePermissions: [], isLoaded: false });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('reads accessiblePermissions from cap.user when loaded', () => {
|
|
50
|
+
const result = getUserAccess({ cap: { user: { accessiblePermissions: [1508, 1509] } } });
|
|
51
|
+
expect(result).toEqual({ accessiblePermissions: [1508, 1509], isLoaded: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('falls back to Global.user when cap.user is absent', () => {
|
|
55
|
+
const result = getUserAccess({ Global: { user: { accessiblePermissions: ['CREATIVE_VIEW'] } } });
|
|
56
|
+
expect(result).toEqual({ accessiblePermissions: ['CREATIVE_VIEW'], isLoaded: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('is not loaded when the user object is empty', () => {
|
|
60
|
+
expect(getUserAccess({ cap: { user: {} } })).toEqual({ accessiblePermissions: [], isLoaded: false });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('defaults accessiblePermissions to [] when a loaded user has none', () => {
|
|
64
|
+
const result = getUserAccess({ cap: { user: { id: 42 } } });
|
|
65
|
+
expect(result).toEqual({ accessiblePermissions: [], isLoaded: true });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('hasPermission', () => {
|
|
70
|
+
it('returns false when userAccess is null or undefined', () => {
|
|
71
|
+
expect(hasPermission(null, CREATIVE_VIEW)).toBe(false);
|
|
72
|
+
expect(hasPermission(undefined, CREATIVE_VIEW)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns false when accessiblePermissions is missing', () => {
|
|
76
|
+
expect(hasPermission({}, CREATIVE_VIEW)).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('matches by permission code', () => {
|
|
80
|
+
expect(hasPermission({ accessiblePermissions: [CREATIVE_CREATE] }, CREATIVE_CREATE)).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('matches by numeric permission id', () => {
|
|
84
|
+
expect(hasPermission({ accessiblePermissions: [1509] }, CREATIVE_CREATE)).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('matches by string permission id', () => {
|
|
88
|
+
expect(hasPermission({ accessiblePermissions: ['1509'] }, CREATIVE_CREATE)).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns false when the permission is not granted', () => {
|
|
92
|
+
expect(hasPermission({ accessiblePermissions: [CREATIVE_VIEW] }, CREATIVE_DELETE)).toBe(false);
|
|
93
|
+
expect(hasPermission({ accessiblePermissions: [] }, CREATIVE_VIEW)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('canViewCreatives', () => {
|
|
98
|
+
it('allows when the new CREATIVE_VIEW permission is present', () => {
|
|
99
|
+
expect(canViewCreatives({ accessiblePermissions: [CREATIVE_VIEW] })).toBe(true);
|
|
100
|
+
expect(canViewCreatives({ accessiblePermissions: [1508] })).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('allows via the legacy CREATIVES_UI_VIEW permission', () => {
|
|
104
|
+
expect(canViewCreatives({ accessiblePermissions: [CREATIVES_UI_VIEW] })).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns a falsy value when userAccess is null', () => {
|
|
108
|
+
// short-circuits on `userAccess &&`, so returns null (falsy) rather than false
|
|
109
|
+
expect(canViewCreatives(null)).toBeFalsy();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns false when neither view permission is present', () => {
|
|
113
|
+
expect(canViewCreatives({ accessiblePermissions: [CREATIVE_CREATE] })).toBe(false);
|
|
114
|
+
expect(canViewCreatives({})).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -209,7 +209,7 @@ describe('normalizeRcsMessageContentForApi', () => {
|
|
|
209
209
|
expect(item.rcsContent.cardContent).toEqual([{ title: 'Card' }]);
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
-
it('strips isSaved from each suggestion in cardContent', () => {
|
|
212
|
+
it('strips isSaved and urlType from each suggestion in cardContent', () => {
|
|
213
213
|
const item = {
|
|
214
214
|
rcsContent: {
|
|
215
215
|
cardContent: [
|
|
@@ -217,7 +217,9 @@ describe('normalizeRcsMessageContentForApi', () => {
|
|
|
217
217
|
title: 'Card 1',
|
|
218
218
|
suggestions: [
|
|
219
219
|
{ type: 'QUICK_REPLY', text: 'Yes', isSaved: true },
|
|
220
|
-
{
|
|
220
|
+
{
|
|
221
|
+
type: 'CTA', text: 'Go', url: 'https://example.com', urlType: 'STATIC', isSaved: false,
|
|
222
|
+
},
|
|
221
223
|
],
|
|
222
224
|
},
|
|
223
225
|
],
|
|
@@ -230,6 +232,56 @@ describe('normalizeRcsMessageContentForApi', () => {
|
|
|
230
232
|
]);
|
|
231
233
|
});
|
|
232
234
|
|
|
235
|
+
it('keeps type "CTA" and the inline tag in url, but drops urlType, for a dynamic-URL suggestion', () => {
|
|
236
|
+
const item = {
|
|
237
|
+
rcsContent: {
|
|
238
|
+
cardContent: [
|
|
239
|
+
{
|
|
240
|
+
title: 'Card 1',
|
|
241
|
+
suggestions: [
|
|
242
|
+
{
|
|
243
|
+
type: 'CTA',
|
|
244
|
+
text: 'Visit here',
|
|
245
|
+
url: 'https://example.com/{{first_name}}',
|
|
246
|
+
urlType: 'DYNAMIC',
|
|
247
|
+
isSaved: true,
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
normalizeRcsMessageContentForApi(item);
|
|
255
|
+
expect(item.rcsContent.cardContent[0].suggestions).toEqual([
|
|
256
|
+
{
|
|
257
|
+
type: 'CTA',
|
|
258
|
+
text: 'Visit here',
|
|
259
|
+
url: 'https://example.com/{{first_name}}',
|
|
260
|
+
},
|
|
261
|
+
]);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('leaves phone number and quick reply suggestions untouched by the urlType stripping', () => {
|
|
265
|
+
const item = {
|
|
266
|
+
rcsContent: {
|
|
267
|
+
cardContent: [
|
|
268
|
+
{
|
|
269
|
+
title: 'Card 1',
|
|
270
|
+
suggestions: [
|
|
271
|
+
{
|
|
272
|
+
type: 'PHONE_NUMBER', text: 'Call', phoneNumber: '918987678', isSaved: true,
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
normalizeRcsMessageContentForApi(item);
|
|
280
|
+
expect(item.rcsContent.cardContent[0].suggestions).toEqual([
|
|
281
|
+
{ type: 'PHONE_NUMBER', text: 'Call', phoneNumber: '918987678' },
|
|
282
|
+
]);
|
|
283
|
+
});
|
|
284
|
+
|
|
233
285
|
it('handles cardContent with no suggestions gracefully', () => {
|
|
234
286
|
const item = {
|
|
235
287
|
rcsContent: {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Access-denied screen: rbac-lock icon + heading + subtext (matches the shared
|
|
2
|
+
// "no access" screen used across Loyalty+ / Org Settings).
|
|
3
|
+
.no-access-icon {
|
|
4
|
+
font-size: 48px;
|
|
5
|
+
color: #8c8c8c;
|
|
6
|
+
}
|
|
7
|
+
.no-access-title {
|
|
8
|
+
margin-top: 16px;
|
|
9
|
+
}
|
|
10
|
+
.no-access-text {
|
|
11
|
+
margin-top: 8px;
|
|
12
|
+
color: #6b778c;
|
|
13
|
+
max-width: 420px;
|
|
14
|
+
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { FormattedMessage } from 'react-intl';
|
|
3
|
-
import { CapRow, CapHeading } from '@capillarytech/cap-ui-library';
|
|
3
|
+
import { CapRow, CapHeading, CapIcon } from '@capillarytech/cap-ui-library';
|
|
4
4
|
import messages from './messages';
|
|
5
|
+
import './_accessForbidden.scss';
|
|
5
6
|
|
|
6
7
|
const AccessForbidden = () => (
|
|
7
8
|
<CapRow type='flex' vertical justify='center' align='middle' className="margin-t-32 text-align-center">
|
|
8
|
-
<
|
|
9
|
+
<CapIcon type="rbac-lock" className="no-access-icon" />
|
|
10
|
+
<CapHeading type="h3" className="no-access-title">
|
|
9
11
|
<FormattedMessage {...messages.forbiddenHeader} />
|
|
10
12
|
</CapHeading>
|
|
11
|
-
<CapHeading type="
|
|
13
|
+
<CapHeading type="h6" className="no-access-text">
|
|
12
14
|
<FormattedMessage {...messages.forbiddenDesc} />
|
|
13
15
|
</CapHeading>
|
|
14
16
|
</CapRow>
|
|
@@ -5,10 +5,11 @@ const prefix = 'creatives.components.AccessForbidden';
|
|
|
5
5
|
export default defineMessages({
|
|
6
6
|
forbiddenHeader: {
|
|
7
7
|
id: `${prefix}.forbiddenHeader`,
|
|
8
|
-
defaultMessage: '
|
|
8
|
+
defaultMessage: "You don't have permission to view this page.",
|
|
9
9
|
},
|
|
10
10
|
forbiddenDesc: {
|
|
11
11
|
id: `${prefix}.forbiddenDesc`,
|
|
12
|
-
defaultMessage:
|
|
12
|
+
defaultMessage:
|
|
13
|
+
'Please contact your organisation administrator to request access.',
|
|
13
14
|
},
|
|
14
15
|
});
|
|
@@ -43,6 +43,7 @@ export const RCS_CTA_URL_TYPE = {
|
|
|
43
43
|
export const BTN_MAX_LENGTH = 20;
|
|
44
44
|
export const PHONE_NUMBER_MAX_LENGTH = 15;
|
|
45
45
|
export const URL_MAX_LENGTH = 2000;
|
|
46
|
+
export const DYNAMIC_URL_SUFFIX = '{{1}}';
|
|
46
47
|
|
|
47
48
|
export const CTA_TYPE_RADIO_OPTIONS = [
|
|
48
49
|
{ label: <FormattedMessage {...messages.ctaPhoneNo} />, value: RCS_BUTTON_TYPES.PHONE_NUMBER },
|