@pushwoosh/smart-components-kit 1.3.21 → 1.3.23

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.
@@ -1,14 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect } from 'react';
3
- import { FieldBox, NativeInput, Select, Spinner } from '@pushwoosh/dumb-components';
3
+ import { Combobox, FieldBox, NativeInput, Spinner } from '@pushwoosh/dumb-components';
4
4
  import { Horizontal, Vertical } from '@pushwoosh/kit-helpers';
5
5
  import { useDynamicContentTags } from './hooks';
6
6
  import { useLang } from './polyglot';
7
7
  import { SimpleTagPickerDiv } from './styled';
8
- import { buildOptions } from './utils';
8
+ import { allDynamicContentModifiers, buildOptions } from './utils';
9
+ const defaultModifier = 'regular';
10
+ const matchesQuery = (value, query) => value.toLowerCase().includes(query.trim().toLowerCase());
9
11
  const defaultDynamicContent = {
10
12
  tagName: '',
11
- modifier: 'regular',
13
+ modifier: defaultModifier,
12
14
  defaultValue: ''
13
15
  };
14
16
  export const SimpleDynamicContentPickerFormSmart = ({
@@ -37,10 +39,19 @@ export const SimpleDynamicContentPickerFormSmart = ({
37
39
  });
38
40
  }
39
41
  const options = buildOptions(systemVariables ?? [], asyncResult.value || []);
40
- const selectedTag = options.find(option => option.name === dc.tagName) || {
41
- name: '',
42
- label: '',
43
- modifiers: ['regular']
42
+ const pickedOption = options.find(option => option.name === dc.tagName);
43
+ const modifiers = pickedOption?.modifiers ?? allDynamicContentModifiers;
44
+ const isEmpty = !dc.tagName.trim();
45
+ const labelOf = name => options.find(option => option.name === name)?.label ?? name;
46
+ const changeTagName = (tagName, isPicked) => {
47
+ const option = options.find(item => item.name === tagName);
48
+ const nextModifiers = option?.modifiers ?? allDynamicContentModifiers;
49
+ const keepModifier = !isPicked && nextModifiers.includes(dc.modifier);
50
+ onChange({
51
+ tagName,
52
+ modifier: keepModifier ? dc.modifier : nextModifiers[0] ?? defaultModifier,
53
+ defaultValue: isPicked ? '' : dc.defaultValue
54
+ });
44
55
  };
45
56
  return _jsxs(SimpleTagPickerDiv, {
46
57
  children: [_jsx(FieldBox, {
@@ -49,43 +60,30 @@ export const SimpleDynamicContentPickerFormSmart = ({
49
60
  }),
50
61
  children: _jsxs(Vertical, {
51
62
  "$gap": 8,
52
- children: [_jsx(Select, {
53
- width: "auto",
54
- menuPlacement: "auto",
55
- value: {
56
- value: dc.tagName,
57
- label: selectedTag.name ? selectedTag.label : dc.tagName
58
- },
59
- onChange: option => {
60
- const picked = options.find(item => item.name === option.value);
61
- onChange({
62
- tagName: option.value,
63
- modifier: picked?.modifiers[0] ?? 'regular',
64
- defaultValue: ''
65
- });
66
- },
67
- options: options.map(option => ({
68
- value: option.name,
69
- label: option.label
70
- }))
71
- }), _jsx(Select, {
72
- width: "auto",
73
- menuPlacement: "auto",
74
- value: {
75
- value: dc.modifier,
76
- label: dc.modifier
77
- },
78
- onChange: option => {
63
+ children: [_jsx(Combobox, {
64
+ value: dc.tagName,
65
+ onChange: (value, meta) => changeTagName(value, meta.source === 'select'),
66
+ items: options.map(option => option.name),
67
+ getLabel: name => name,
68
+ renderItem: name => labelOf(name),
69
+ filter: (name, query) => matchesQuery(name, query) || matchesQuery(labelOf(name), query),
70
+ placeholder: lang({
71
+ message: 'SMART.DYNAMIC_CONTENT_PICKER_FORM.TAG.PLACEHOLDER'
72
+ }),
73
+ fullWidth: true
74
+ }), _jsx(Combobox, {
75
+ select: true,
76
+ value: dc.modifier || null,
77
+ onChange: modifier => {
79
78
  onChange({
80
79
  ...dc,
81
- modifier: option.value
80
+ modifier: modifier ?? ''
82
81
  });
83
82
  },
84
- options: selectedTag.modifiers.map(modifier => ({
85
- value: modifier,
86
- label: modifier
87
- })),
88
- isDisabled: !selectedTag.name
83
+ items: modifiers,
84
+ getLabel: modifier => modifier,
85
+ disabled: isEmpty,
86
+ fullWidth: true
89
87
  })]
90
88
  })
91
89
  }), _jsx(FieldBox, {
@@ -100,7 +98,7 @@ export const SimpleDynamicContentPickerFormSmart = ({
100
98
  defaultValue: e.target.value
101
99
  });
102
100
  },
103
- disabled: !selectedTag.name
101
+ disabled: isEmpty
104
102
  })
105
103
  }), children]
106
104
  });
@@ -1,3 +1,4 @@
1
1
  import { type FullTagData, type PickerOption, type SystemVariable, type VariedDynamicContent } from './types';
2
2
  export declare function buildOptions(systemVariables: SystemVariable[], tags: FullTagData[]): PickerOption[];
3
3
  export declare function createDefaultDynamicContent(kind: 'tag' | 'event' | 'customText'): VariedDynamicContent;
4
+ export declare const allDynamicContentModifiers: string[];
@@ -1,3 +1,4 @@
1
+ import { DynamicContentModifiers } from '@pushwoosh/kit-constants';
1
2
  export function buildOptions(systemVariables, tags) {
2
3
  const taken = new Set(systemVariables.map(v => v.name));
3
4
  const system = systemVariables.map(v => ({
@@ -40,4 +41,5 @@ export function createDefaultDynamicContent(kind) {
40
41
  }
41
42
  };
42
43
  }
43
- }
44
+ }
45
+ export const allDynamicContentModifiers = [...DynamicContentModifiers.string, ...DynamicContentModifiers.integer.filter(modifier => !DynamicContentModifiers.string.includes(modifier))];
@@ -19,16 +19,20 @@ export function ProductUpdates({
19
19
  isLoading,
20
20
  hasUpdates,
21
21
  markAllRead,
22
- clearNewBadges
22
+ clearNewBadges,
23
+ isImpersonation,
24
+ isImpersonationResolved
23
25
  } = useProductUpdates();
26
+ // Staff in impersonation must not auto-open: the modal would spend the client's read marks.
27
+ // Unresolved means the token is still being read, so hold rather than open and find out later.
24
28
  useEffect(() => {
25
- if (!isAllowed || !autoOpenOnUnread || hasAutoOpened.current || unreadCount === 0) {
29
+ if (!isAllowed || !autoOpenOnUnread || hasAutoOpened.current || unreadCount === 0 || !isImpersonationResolved || isImpersonation) {
26
30
  return;
27
31
  }
28
32
  hasAutoOpened.current = true;
29
33
  setIsOpen(true);
30
34
  markAllRead();
31
- }, [isAllowed, autoOpenOnUnread, unreadCount, markAllRead]);
35
+ }, [isAllowed, autoOpenOnUnread, unreadCount, markAllRead, isImpersonation, isImpersonationResolved]);
32
36
  const handleOpen = () => {
33
37
  setIsOpen(true);
34
38
  markAllRead();
@@ -8,6 +8,10 @@ type UseProductUpdates = {
8
8
  readonly markAllRead: () => Promise<void>;
9
9
  /** Drops the NEW badges and the unread counter. Call on close, so they last while reading. */
10
10
  readonly clearNewBadges: () => void;
11
+ /** Staff viewing a client account from the super admin: no marks are written for them. */
12
+ readonly isImpersonation: boolean;
13
+ /** False until the access token is read, so an auto-open effect can hold instead of racing it. */
14
+ readonly isImpersonationResolved: boolean;
11
15
  };
12
16
  export declare function useProductUpdates(): UseProductUpdates;
13
17
  export {};
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useRef, useState } from 'react';
2
2
  import { useAsync } from '@pushwoosh/kit-helpers';
3
3
  import { useSPAContext } from '@pushwoosh/spa-context';
4
+ import { useIsStaffImpersonation } from '../../staffImpersonation';
4
5
  import { ALERT_STATUS_READ, ALERT_STATUS_UNREAD, ALERT_TYPE_PRODUCT_UPDATE, ORDER_BY_CREATED, ORDER_DIRECTION_DESC, MAX_VISIBLE_UPDATES } from '../constants';
5
6
  import { useIsAllowProductUpdates } from './useIsAllowProductUpdates';
6
7
  const EMPTY = [];
@@ -9,6 +10,10 @@ export function useProductUpdates() {
9
10
  rpcGateway
10
11
  } = useSPAContext();
11
12
  const isAllowed = useIsAllowProductUpdates();
13
+ const {
14
+ isImpersonation,
15
+ isResolved
16
+ } = useIsStaffImpersonation();
12
17
  const [areBadgesCleared, setAreBadgesCleared] = useState(false);
13
18
  const hasSentRef = useRef(false);
14
19
  const state = useAsync(async () => {
@@ -32,7 +37,9 @@ export function useProductUpdates() {
32
37
  // The mark goes out on open, not on close: a reload with the modal still open is not
33
38
  // a close, and the same updates used to pop again.
34
39
  const markAllRead = useCallback(async () => {
35
- if (hasSentRef.current) {
40
+ // Read marks belong to the client's user, so staff writes none, and an unresolved check holds
41
+ // the mark rather than guessing. The flag is set before the first await: two calls send once.
42
+ if (hasSentRef.current || isImpersonation || !isResolved) {
36
43
  return;
37
44
  }
38
45
  const unread = fetched.filter(update => update.status === ALERT_STATUS_UNREAD);
@@ -51,7 +58,7 @@ export function useProductUpdates() {
51
58
  console.error('Failed to mark product updates as read:', error);
52
59
  hasSentRef.current = false;
53
60
  }
54
- }, [rpcGateway, fetched]);
61
+ }, [rpcGateway, fetched, isImpersonation, isResolved]);
55
62
  const clearNewBadges = useCallback(() => setAreBadgesCleared(true), []);
56
63
  return {
57
64
  updates,
@@ -59,6 +66,8 @@ export function useProductUpdates() {
59
66
  isLoading: state.loading,
60
67
  hasUpdates: updates.length > 0,
61
68
  markAllRead,
62
- clearNewBadges
69
+ clearNewBadges,
70
+ isImpersonation,
71
+ isImpersonationResolved: isResolved
63
72
  };
64
73
  }
@@ -21,7 +21,7 @@ export const NewDynamicContentForm = ({
21
21
  children: _jsx(Button, {
22
22
  color: "secondary",
23
23
  onClick: () => onSubmit(dc),
24
- isDisabled: !dc,
24
+ isDisabled: !dc?.tagName.trim(),
25
25
  children: lang({
26
26
  message: 'SMART.RICH_MESSAGE_EDITOR.INSERT'
27
27
  })
package/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export * from './EmailTemplatePreview';
7
7
  export * from './ExportCSV';
8
8
  export * from './PlatformsConfigurationDrawer';
9
9
  export * from './ProductUpdates';
10
+ export * from './staffImpersonation';
10
11
  export * from './LanguagesSelector';
11
12
  export * from './MediaStorage';
12
13
  export * from './mmsAttachments';
package/index.js CHANGED
@@ -7,6 +7,7 @@ export * from './EmailTemplatePreview';
7
7
  export * from './ExportCSV';
8
8
  export * from './PlatformsConfigurationDrawer';
9
9
  export * from './ProductUpdates';
10
+ export * from './staffImpersonation';
10
11
  export * from './LanguagesSelector';
11
12
  export * from './MediaStorage';
12
13
  export * from './mmsAttachments';
package/package.json CHANGED
@@ -33,6 +33,7 @@
33
33
  "@pushwoosh/rpc-v2-http-api-data": "^0.2.151",
34
34
  "@pushwoosh/spa-context": "^1.7.1",
35
35
  "idb-keyval": "^6.2.2",
36
+ "jwt-decode": "^4.0.0",
36
37
  "react-hook-form": "^7.68.0",
37
38
  "react-router-dom": "^5.3.4"
38
39
  },
@@ -54,5 +55,5 @@
54
55
  "react": "^16.14.0 || ^18.0.0",
55
56
  "react-dom": "^16.14.0 || ^18.0.0"
56
57
  },
57
- "version": "1.3.21"
58
+ "version": "1.3.23"
58
59
  }
@@ -0,0 +1 @@
1
+ export { useIsStaffImpersonation } from './useIsStaffImpersonation';
@@ -0,0 +1 @@
1
+ export { useIsStaffImpersonation } from './useIsStaffImpersonation';
@@ -0,0 +1,5 @@
1
+ type AccessTokenSource = {
2
+ readonly getAccessToken: () => Promise<string>;
3
+ };
4
+ export declare function isStaffImpersonation(httpClient: AccessTokenSource): Promise<boolean>;
5
+ export {};
@@ -0,0 +1,13 @@
1
+ import { jwtDecode } from 'jwt-decode';
2
+ // Staff coming from the super admin gets is_su with no parent user; a client switched into
3
+ // their own child account gets is_su too, but with the parent user id filled in.
4
+ export async function isStaffImpersonation(httpClient) {
5
+ try {
6
+ const claims = jwtDecode(await httpClient.getAccessToken());
7
+ const hasParentUser = claims.parent_user_id != null && claims.parent_user_id !== '0';
8
+ return claims.is_su === true && !hasParentUser;
9
+ } catch {
10
+ // Fail open: a client must not lose the feature because the token could not be read.
11
+ return false;
12
+ }
13
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isStaffImpersonation } from './isStaffImpersonation';
3
+ function tokenWith(claims) {
4
+ const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
5
+ return `eyJhbGciOiJub25lIn0.${payload}.signature`;
6
+ }
7
+ function clientWith(token) {
8
+ return {
9
+ getAccessToken: async () => {
10
+ if (token instanceof Error) {
11
+ throw token;
12
+ }
13
+ return token;
14
+ }
15
+ };
16
+ }
17
+ describe('isStaffImpersonation', () => {
18
+ it('is false for a plain client login', async () => {
19
+ await expect(isStaffImpersonation(clientWith(tokenWith({
20
+ account_id: 42
21
+ })))).resolves.toBe(false);
22
+ });
23
+ it('is true for staff impersonation without parent_user_id', async () => {
24
+ await expect(isStaffImpersonation(clientWith(tokenWith({
25
+ is_su: true
26
+ })))).resolves.toBe(true);
27
+ });
28
+ it('is true for staff impersonation with parent_user_id "0"', async () => {
29
+ await expect(isStaffImpersonation(clientWith(tokenWith({
30
+ is_su: true,
31
+ parent_user_id: '0'
32
+ })))).resolves.toBe(true);
33
+ });
34
+ it('is false for a client switched into their own child account', async () => {
35
+ await expect(isStaffImpersonation(clientWith(tokenWith({
36
+ is_su: true,
37
+ parent_user_id: '77123'
38
+ })))).resolves.toBe(false);
39
+ });
40
+ it('is false when the token carries no claims at all', async () => {
41
+ await expect(isStaffImpersonation(clientWith(tokenWith({})))).resolves.toBe(false);
42
+ });
43
+ it('is false for a garbage token', async () => {
44
+ await expect(isStaffImpersonation(clientWith('not-a-jwt'))).resolves.toBe(false);
45
+ });
46
+ it('is false when there is no token to read', async () => {
47
+ await expect(isStaffImpersonation(clientWith(new Error('Unauthorized!')))).resolves.toBe(false);
48
+ });
49
+ });
@@ -0,0 +1,7 @@
1
+ type UseIsStaffImpersonation = {
2
+ readonly isImpersonation: boolean;
3
+ /** False until the token is read, so a caller can hold an effect instead of racing it. */
4
+ readonly isResolved: boolean;
5
+ };
6
+ export declare function useIsStaffImpersonation(): UseIsStaffImpersonation;
7
+ export {};
@@ -0,0 +1,13 @@
1
+ import { useAsync } from '@pushwoosh/kit-helpers';
2
+ import { useSPAContext } from '@pushwoosh/spa-context';
3
+ import { isStaffImpersonation } from './isStaffImpersonation';
4
+ export function useIsStaffImpersonation() {
5
+ const {
6
+ httpClient
7
+ } = useSPAContext();
8
+ const state = useAsync(() => isStaffImpersonation(httpClient), [httpClient]);
9
+ return {
10
+ isImpersonation: state.value ?? false,
11
+ isResolved: !state.loading
12
+ };
13
+ }