@blocklet/ui-react 2.4.24 → 2.4.27

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.
@@ -7,6 +7,8 @@ exports.default = void 0;
7
7
 
8
8
  var _react = require("react");
9
9
 
10
+ var _propTypes = _interopRequireDefault(require("prop-types"));
11
+
10
12
  var _Session = require("@arcblock/did-connect/lib/Session");
11
13
 
12
14
  var _context = require("@arcblock/ux/lib/Locale/context");
@@ -23,9 +25,11 @@ var _utils = require("../utils");
23
25
 
24
26
  var _blocklets = require("../blocklets");
25
27
 
28
+ var _headerAddons2 = _interopRequireDefault(require("../common/header-addons"));
29
+
26
30
  var _jsxRuntime = require("react/jsx-runtime");
27
31
 
28
- const _excluded = ["meta"];
32
+ const _excluded = ["meta", "fallbackUrl", "invalidPathFallback", "headerAddons", "sessionManagerProps"];
29
33
 
30
34
  function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
31
35
 
@@ -39,43 +43,25 @@ function _objectWithoutProperties(source, excluded) { if (source == null) return
39
43
 
40
44
  function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }
41
45
 
42
- /**
43
- * 根据 role 筛选 nav, 规则:
44
- * - 如果是枝结点, 必须至少存在一个符合条件的子结点
45
- * - role 未定义, 符合条件
46
- * - role 定义且包括当前的 userRole, 符合条件
47
- *
48
- * @param {object[]} nav 导航菜单数据
49
- * @param {string} userRole 当前用户 role
50
- * @returns 符合 role 权限的导航菜单数据
51
- */
52
- function filterNavByRole(nav, userRole) {
53
- return (0, _utils.filterRecursive)(nav, (item, context) => {
54
- const isRoleMatched = !item.role || userRole && item.role.includes(userRole);
55
-
56
- if (!context.isLeaf) {
57
- var _context$filteredChil;
58
-
59
- return isRoleMatched && ((_context$filteredChil = context.filteredChildren) === null || _context$filteredChil === void 0 ? void 0 : _context$filteredChil.length);
60
- }
61
-
62
- return isRoleMatched;
63
- }, 'items');
64
- }
65
46
  /**
66
47
  * 专门用于 (composable) blocklet 的 Dashboard 组件, 解析 blocklet meta 中 section 为 dashboard 的 navigation 数据, 渲染一个 UX Dashboard
67
48
  */
68
-
69
-
49
+ // eslint-disable-next-line no-shadow
70
50
  function Dashboard(_ref) {
71
- var _formattedBlocklet$na, _sessionCtx$session, _sessionCtx$session$u;
51
+ var _sessionCtx$session;
72
52
 
73
53
  let {
74
- meta
54
+ meta,
55
+ fallbackUrl,
56
+ invalidPathFallback,
57
+ headerAddons,
58
+ sessionManagerProps
75
59
  } = _ref,
76
60
  rest = _objectWithoutProperties(_ref, _excluded);
77
61
 
78
62
  const sessionCtx = (0, _react.useContext)(_Session.SessionContext);
63
+ const user = sessionCtx === null || sessionCtx === void 0 ? void 0 : (_sessionCtx$session = sessionCtx.session) === null || _sessionCtx$session === void 0 ? void 0 : _sessionCtx$session.user;
64
+ const userRole = user === null || user === void 0 ? void 0 : user.role;
79
65
  const {
80
66
  locale
81
67
  } = (0, _context.useLocaleContext)() || {};
@@ -88,6 +74,63 @@ function Dashboard(_ref) {
88
74
  return blocklet;
89
75
  }
90
76
  }, [blocklet]);
77
+ const {
78
+ localizedNav,
79
+ flattened,
80
+ matchedIndex
81
+ } = (0, _react.useMemo)(() => {
82
+ var _formattedBlocklet$na;
83
+
84
+ let localizedNav = (0, _blocklets.getLocalizedNavigation)(formattedBlocklet === null || formattedBlocklet === void 0 ? void 0 : (_formattedBlocklet$na = formattedBlocklet.navigation) === null || _formattedBlocklet$na === void 0 ? void 0 : _formattedBlocklet$na.dashboard, locale) || []; // 根据 role 筛选 nav 数据
85
+
86
+ localizedNav = (0, _blocklets.filterNavByRole)(localizedNav, userRole); // 将 nav 数据处理成 ux dashboard 需要的格式
87
+
88
+ localizedNav = (0, _utils.mapRecursive)(localizedNav, item => ({
89
+ title: item.title,
90
+ url: item.link,
91
+ icon: item.icon ? /*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
92
+ className: "iconify",
93
+ "data-icon": item.icon
94
+ }) : null,
95
+ // https://github.com/ArcBlock/ux/issues/755#issuecomment-1208692620
96
+ external: true,
97
+ children: item.items
98
+ }), 'items'); // 展平后使用 matchPaths 检测 link#active 状态
99
+
100
+ const flattened = (0, _utils.flatRecursive)(localizedNav).filter(item => !!item.url);
101
+ const matchedIndex = (0, _utils.matchPaths)(flattened.map(item => item.url));
102
+
103
+ if (flattened !== null && flattened !== void 0 && flattened.length) {
104
+ flattened[matchedIndex === -1 ? 0 : matchedIndex].active = true;
105
+ }
106
+
107
+ return {
108
+ localizedNav,
109
+ flattened,
110
+ matchedIndex
111
+ };
112
+ }, [formattedBlocklet, locale, userRole]); // 页面初始化时, 如果当前用户没有权限访问任何导航菜单 (比如登录时未提供 VC 导致无权限), 则跳转到 fallbackUrl
113
+ // 未认证 (user 为空) 时不做处理, 这种情况的页面跳转逻辑一般由应用自行处理
114
+
115
+ (0, _react.useLayoutEffect)(() => {
116
+ if (!!user && !(flattened !== null && flattened !== void 0 && flattened.length) && fallbackUrl) {
117
+ window.location.href = fallbackUrl;
118
+ } // eslint-disable-next-line react-hooks/exhaustive-deps
119
+
120
+ }, [fallbackUrl]); // 导航菜单变动且存在可用菜单但无匹配项时 (如切换 passport), 跳转到首个菜单项
121
+
122
+ (0, _react.useLayoutEffect)(() => {
123
+ if (!!user && !!(flattened !== null && flattened !== void 0 && flattened.length) && matchedIndex === -1) {
124
+ if (invalidPathFallback) {
125
+ invalidPathFallback();
126
+ } else {
127
+ var _flattened$;
128
+
129
+ window.location.href = ((_flattened$ = flattened[0]) === null || _flattened$ === void 0 ? void 0 : _flattened$.url) || _blocklets.publicPath;
130
+ }
131
+ } // eslint-disable-next-line react-hooks/exhaustive-deps
132
+
133
+ }, [invalidPathFallback]);
91
134
 
92
135
  if (!blocklet.appName) {
93
136
  return null;
@@ -98,28 +141,12 @@ function Dashboard(_ref) {
98
141
  appLogo,
99
142
  appName
100
143
  } = formattedBlocklet;
101
- let localizedNav = (0, _blocklets.getLocalizedNavigation)(formattedBlocklet === null || formattedBlocklet === void 0 ? void 0 : (_formattedBlocklet$na = formattedBlocklet.navigation) === null || _formattedBlocklet$na === void 0 ? void 0 : _formattedBlocklet$na.dashboard, locale) || []; // 根据 role 筛选 nav 数据
102
-
103
- localizedNav = filterNavByRole(localizedNav, sessionCtx === null || sessionCtx === void 0 ? void 0 : (_sessionCtx$session = sessionCtx.session) === null || _sessionCtx$session === void 0 ? void 0 : (_sessionCtx$session$u = _sessionCtx$session.user) === null || _sessionCtx$session$u === void 0 ? void 0 : _sessionCtx$session$u.role); // 将 nav 数据处理成 ux dashboard 需要的格式
104
-
105
- localizedNav = (0, _utils.mapRecursive)(localizedNav, item => ({
106
- title: item.title,
107
- url: item.link,
108
- icon: item.icon ? /*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
109
- className: "iconify",
110
- "data-icon": item.icon
111
- }) : null,
112
- // https://github.com/ArcBlock/ux/issues/755#issuecomment-1208692620
113
- external: true,
114
- children: item.items
115
- }), 'items'); // 展平后使用 matchPaths 检测 link#active 状态
116
-
117
- const flattened = (0, _utils.flatRecursive)(localizedNav).filter(item => !!item.url);
118
- const matchedIndex = (0, _utils.matchPaths)(flattened.map(item => item.url));
119
-
120
- if (flattened !== null && flattened !== void 0 && flattened.length) {
121
- flattened[matchedIndex === -1 ? 0 : matchedIndex].active = true;
122
- }
144
+
145
+ const _headerAddons = /*#__PURE__*/(0, _jsxRuntime.jsx)(_headerAddons2.default, {
146
+ formattedBlocklet: formattedBlocklet,
147
+ addons: headerAddons,
148
+ sessionManagerProps: sessionManagerProps
149
+ });
123
150
 
124
151
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_dashboard.default, _objectSpread(_objectSpread({
125
152
  title: appName,
@@ -149,17 +176,34 @@ function Dashboard(_ref) {
149
176
  src: appLogo,
150
177
  alt: "logo"
151
178
  })
152
- })
179
+ }),
180
+ addons: _headerAddons
153
181
  }, rest.headerProps),
154
182
  links: localizedNav
155
183
  }));
156
184
  }
157
185
 
158
186
  Dashboard.propTypes = {
159
- meta: _types.blockletMetaProps
187
+ meta: _types.blockletMetaProps,
188
+ // 如果当前用户没有权限访问任何导航菜单, 则自动跳转到 fallbackUrl, 默认值为 publicPath, 设置为 null 表示禁用自动跳转
189
+ fallbackUrl: _propTypes.default.string,
190
+ // 当前路径未匹配任何 nav links 时的 fallback, 默认行为跳转到首个可用的 nav link
191
+ invalidPathFallback: _propTypes.default.func,
192
+ headerAddons: _propTypes.default.oneOfType([_propTypes.default.func, _propTypes.default.node]),
193
+ sessionManagerProps: _types.sessionManagerProps
160
194
  };
161
195
  Dashboard.defaultProps = {
162
- meta: {}
196
+ meta: {},
197
+ fallbackUrl: _blocklets.publicPath,
198
+ invalidPathFallback: null,
199
+ headerAddons: undefined,
200
+ sessionManagerProps: {
201
+ showRole: true,
202
+ // dashboard 默认退出登录行为: 跳转到 (root) blocklet 首页
203
+ onLogout: () => {
204
+ window.location.href = _blocklets.publicPath;
205
+ }
206
+ }
163
207
  };
164
208
  var _default = Dashboard;
165
209
  exports.default = _default;
@@ -7,8 +7,6 @@ exports.default = void 0;
7
7
 
8
8
  var _react = require("react");
9
9
 
10
- var _jsxRuntime = require("react/jsx-runtime");
11
-
12
10
  var _propTypes = _interopRequireDefault(require("prop-types"));
13
11
 
14
12
  var _reactErrorBoundary = require("react-error-boundary");
@@ -21,12 +19,6 @@ var _Header = require("@arcblock/ux/lib/Header");
21
19
 
22
20
  var _NavMenu = _interopRequireDefault(require("@arcblock/ux/lib/NavMenu"));
23
21
 
24
- var _Session = require("@arcblock/did-connect/lib/Session");
25
-
26
- var _SessionManager = _interopRequireDefault(require("@arcblock/did-connect/lib/SessionManager"));
27
-
28
- var _selector = _interopRequireDefault(require("@arcblock/ux/lib/Locale/selector"));
29
-
30
22
  var _context = require("@arcblock/ux/lib/Locale/context");
31
23
 
32
24
  var _Address = _interopRequireDefault(require("@arcblock/did-connect/lib/Address"));
@@ -43,6 +35,10 @@ var _utils = require("../utils");
43
35
 
44
36
  var _blocklets = require("../blocklets");
45
37
 
38
+ var _headerAddons = _interopRequireDefault(require("../common/header-addons"));
39
+
40
+ var _jsxRuntime = require("react/jsx-runtime");
41
+
46
42
  var _templateObject;
47
43
 
48
44
  const _excluded = ["meta", "addons", "sessionManagerProps", "homeLink", "theme"];
@@ -135,7 +131,6 @@ function Header(_ref) {
135
131
  } = _ref,
136
132
  rest = _objectWithoutProperties(_ref, _excluded);
137
133
 
138
- const sessionInfo = (0, _react.useContext)(_Session.SessionContext);
139
134
  const {
140
135
  locale
141
136
  } = (0, _context.useLocaleContext)() || {};
@@ -157,9 +152,7 @@ function Header(_ref) {
157
152
  did,
158
153
  appLogo,
159
154
  appName,
160
- theme,
161
- enableConnect = true,
162
- enableLocale = true
155
+ theme
163
156
  } = formattedBlocklet;
164
157
  const navigation = (0, _blocklets.getLocalizedNavigation)(formattedBlocklet === null || formattedBlocklet === void 0 ? void 0 : (_formattedBlocklet$na = formattedBlocklet.navigation) === null || _formattedBlocklet$na === void 0 ? void 0 : _formattedBlocklet$na.header, locale);
165
158
  const parsedNavigation = parseNavigation(navigation);
@@ -168,39 +161,15 @@ function Header(_ref) {
168
161
  activeId
169
162
  } = parsedNavigation;
170
163
 
171
- const renderAddons = () => {
172
- // 不关心内置的 session manager 和 locale selector, 直接覆盖 UX Header 的 addons
173
- if (addons && typeof addons !== 'function') {
174
- return Array.isArray(addons) ? addons : [addons];
175
- }
176
-
177
- let addonsArray = []; // 启用了多语言并且检测到了 locale context
164
+ const _addons = typeof addons === 'function' ? builtInAddons => addons(builtInAddons, {
165
+ navigation: parsedNavigation
166
+ }) : addons;
178
167
 
179
- if (enableLocale && locale) {
180
- addonsArray.push( /*#__PURE__*/(0, _jsxRuntime.jsx)(_selector.default, {
181
- showText: false
182
- }, "locale-selector"));
183
- } // 启用了连接钱包并且检测到了 session context
184
-
185
-
186
- if (enableConnect && sessionInfo) {
187
- addonsArray.push( /*#__PURE__*/(0, _jsxRuntime.jsx)(_SessionManager.default, _objectSpread({
188
- session: sessionInfo.session
189
- }, sessionManagerProps), "session-manager"));
190
- } // 在内置 addons 基础上定制 addons
191
-
192
-
193
- if (typeof addons === 'function') {
194
- addonsArray = addons(addonsArray, {
195
- navigation: parsedNavigation
196
- }) || [];
197
- }
198
-
199
- return addonsArray;
200
- };
201
-
202
- const renderedAddons = renderAddons();
203
- const addonList = /*#__PURE__*/(0, _react.createElement)(_jsxRuntime.Fragment, null, ...(Array.isArray(renderedAddons) ? renderedAddons : [renderedAddons]));
168
+ const headerAddons = /*#__PURE__*/(0, _jsxRuntime.jsx)(_headerAddons.default, {
169
+ formattedBlocklet: formattedBlocklet,
170
+ addons: _addons,
171
+ sessionManagerProps: sessionManagerProps
172
+ });
204
173
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_overridableThemeProvider.default, {
205
174
  theme: themeOverrides,
206
175
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(StyledUxHeader, _objectSpread(_objectSpread({
@@ -226,7 +195,7 @@ function Header(_ref) {
226
195
  }),
227
196
  children: did
228
197
  }) : null,
229
- addons: addonList
198
+ addons: headerAddons
230
199
  }, rest), {}, {
231
200
  $bgcolor: theme === null || theme === void 0 ? void 0 : (_theme$background = theme.background) === null || _theme$background === void 0 ? void 0 : _theme$background.header,
232
201
  children: !(navItems !== null && navItems !== void 0 && navItems.length) ? null : _ref2 => {
package/lib/blocklets.js CHANGED
@@ -3,7 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.publicPath = exports.parseNavigation = exports.getLocalizedNavigation = exports.formatTheme = exports.formatNavigation = exports.formatBlockletInfo = void 0;
6
+ exports.publicPath = exports.parseNavigation = exports.getLocalizedNavigation = exports.formatTheme = exports.formatNavigation = exports.formatBlockletInfo = exports.filterNavByRole = void 0;
7
7
 
8
8
  var _utils = require("./utils");
9
9
 
@@ -93,9 +93,12 @@ const getLocalizedNavigation = function getLocalizedNavigation(navigation) {
93
93
  };
94
94
 
95
95
  return (0, _utils.mapRecursive)(navigation, item => {
96
+ var _item$items;
97
+
96
98
  return _objectSpread(_objectSpread({}, item), {}, {
97
99
  title: getTitle(item.title, locale),
98
- link: getLink(item.link, locale)
100
+ // 仅对叶结点进行处理
101
+ link: !((_item$items = item.items) !== null && _item$items !== void 0 && _item$items.length) ? getLink(item.link, locale) : item.link
99
102
  });
100
103
  }, 'items');
101
104
  };
@@ -136,7 +139,9 @@ const parseNavigation = navigation => {
136
139
  // 对应 footer 底部 links
137
140
  bottom: [],
138
141
  // 对应 dashboard#sidenav 导航
139
- dashboard: []
142
+ dashboard: [],
143
+ // session manager menus
144
+ sessionManager: []
140
145
  }; // 对 navigation 顶层元素按 section 分组
141
146
 
142
147
  formattedNav.forEach(item => {
@@ -177,5 +182,32 @@ const formatBlockletInfo = blockletInfo => {
177
182
  formatted.navigation = parseNavigation(formatted.navigation);
178
183
  return formatted;
179
184
  };
185
+ /**
186
+ * 根据 role 筛选 nav, 规则:
187
+ * - 如果是枝结点, 必须至少存在一个符合条件的子结点
188
+ * - role 未定义, 符合条件
189
+ * - role 定义且包括当前的 userRole, 符合条件
190
+ *
191
+ * @param {object[]} nav 导航菜单数据
192
+ * @param {string} userRole 当前用户 role
193
+ * @returns 符合 role 权限的导航菜单数据
194
+ */
195
+
196
+
197
+ exports.formatBlockletInfo = formatBlockletInfo;
198
+
199
+ const filterNavByRole = (nav, userRole) => {
200
+ return (0, _utils.filterRecursive)(nav, (item, context) => {
201
+ const isRoleMatched = !item.role || userRole && item.role.includes(userRole);
202
+
203
+ if (!context.isLeaf) {
204
+ var _context$filteredChil;
205
+
206
+ return isRoleMatched && ((_context$filteredChil = context.filteredChildren) === null || _context$filteredChil === void 0 ? void 0 : _context$filteredChil.length);
207
+ }
208
+
209
+ return isRoleMatched;
210
+ }, 'items');
211
+ };
180
212
 
181
- exports.formatBlockletInfo = formatBlockletInfo;
213
+ exports.filterNavByRole = filterNavByRole;
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = HeaderAddons;
7
+
8
+ var _react = require("react");
9
+
10
+ var _propTypes = _interopRequireDefault(require("prop-types"));
11
+
12
+ var _jsxRuntime = require("react/jsx-runtime");
13
+
14
+ var _Session = require("@arcblock/did-connect/lib/Session");
15
+
16
+ var _SessionManager = _interopRequireDefault(require("@arcblock/did-connect/lib/SessionManager"));
17
+
18
+ var _selector = _interopRequireDefault(require("@arcblock/ux/lib/Locale/selector"));
19
+
20
+ var _context = require("@arcblock/ux/lib/Locale/context");
21
+
22
+ var _types = require("../types");
23
+
24
+ var _blocklets = require("../blocklets");
25
+
26
+ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
27
+
28
+ function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
29
+
30
+ function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
31
+
32
+ function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
33
+
34
+ // eslint-disable-next-line no-shadow
35
+ function HeaderAddons(_ref) {
36
+ var _sessionCtx$session, _formattedBlocklet$na, _sessionCtx$session2, _sessionCtx$session2$;
37
+
38
+ let {
39
+ formattedBlocklet,
40
+ addons,
41
+ sessionManagerProps
42
+ } = _ref;
43
+ const sessionCtx = (0, _react.useContext)(_Session.SessionContext);
44
+ const {
45
+ locale
46
+ } = (0, _context.useLocaleContext)() || {};
47
+ const {
48
+ enableConnect = true,
49
+ enableLocale = true
50
+ } = formattedBlocklet;
51
+ const authenticated = !!(sessionCtx !== null && sessionCtx !== void 0 && (_sessionCtx$session = sessionCtx.session) !== null && _sessionCtx$session !== void 0 && _sessionCtx$session.user);
52
+ let localizedNav = (0, _blocklets.getLocalizedNavigation)(formattedBlocklet === null || formattedBlocklet === void 0 ? void 0 : (_formattedBlocklet$na = formattedBlocklet.navigation) === null || _formattedBlocklet$na === void 0 ? void 0 : _formattedBlocklet$na.sessionManager, locale) || []; // 根据 role 筛选 nav 数据
53
+
54
+ localizedNav = (0, _blocklets.filterNavByRole)(localizedNav, sessionCtx === null || sessionCtx === void 0 ? void 0 : (_sessionCtx$session2 = sessionCtx.session) === null || _sessionCtx$session2 === void 0 ? void 0 : (_sessionCtx$session2$ = _sessionCtx$session2.user) === null || _sessionCtx$session2$ === void 0 ? void 0 : _sessionCtx$session2$.role);
55
+
56
+ const renderAddons = () => {
57
+ // 不关心内置的 session manager 和 locale selector, 直接覆盖 UX Header 的 addons
58
+ if (addons && typeof addons !== 'function') {
59
+ return Array.isArray(addons) ? addons : [addons];
60
+ }
61
+
62
+ let addonsArray = []; // 启用了多语言并且检测到了 locale context
63
+
64
+ if (enableLocale && locale) {
65
+ addonsArray.push( /*#__PURE__*/(0, _jsxRuntime.jsx)(_selector.default, {
66
+ showText: false
67
+ }, "locale-selector"));
68
+ } // 启用了连接钱包并且检测到了 session context
69
+
70
+
71
+ if (enableConnect && sessionCtx) {
72
+ var _localizedNav;
73
+
74
+ const menu = [];
75
+
76
+ if (authenticated && (_localizedNav = localizedNav) !== null && _localizedNav !== void 0 && _localizedNav.length) {
77
+ localizedNav.forEach(item => {
78
+ menu.push({
79
+ label: item.title,
80
+ icon: item.icon ? /*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
81
+ className: "iconify",
82
+ "data-icon": item.icon,
83
+ "data-height": "24",
84
+ style: {
85
+ marginRight: 16
86
+ }
87
+ }) : null,
88
+ component: 'a',
89
+ href: item.link,
90
+ key: item.link
91
+ });
92
+ });
93
+ }
94
+
95
+ addonsArray.push( /*#__PURE__*/(0, _jsxRuntime.jsx)(_SessionManager.default, _objectSpread({
96
+ session: sessionCtx.session,
97
+ locale: locale,
98
+ menu: menu
99
+ }, sessionManagerProps), "session-manager"));
100
+ } // 在内置 addons 基础上定制 addons
101
+
102
+
103
+ if (typeof addons === 'function') {
104
+ addonsArray = addons(addonsArray) || [];
105
+ }
106
+
107
+ return addonsArray;
108
+ };
109
+
110
+ const renderedAddons = renderAddons();
111
+ const addonList = /*#__PURE__*/(0, _react.createElement)(_jsxRuntime.Fragment, null, ...(Array.isArray(renderedAddons) ? renderedAddons : [renderedAddons]));
112
+ return addonList;
113
+ }
114
+
115
+ HeaderAddons.propTypes = {
116
+ formattedBlocklet: _propTypes.default.object.isRequired,
117
+ // 需要考虑 定制的 addons 与内置的 连接钱包/选择语言 addons 共存的情况
118
+ // - PropTypes.func: 可以把自定义 addons 插在 session-manager 或 locale-selector (如果存在的话) 前/中/后
119
+ // - PropTypes.node: 将 addons 原样传给 UX Header 组件
120
+ addons: _propTypes.default.oneOfType([_propTypes.default.func, _propTypes.default.node]),
121
+ sessionManagerProps: _types.sessionManagerProps
122
+ };
123
+ HeaderAddons.defaultProps = {
124
+ addons: null,
125
+ sessionManagerProps: {
126
+ showRole: true
127
+ }
128
+ };
package/lib/types.js CHANGED
@@ -20,11 +20,11 @@ const blockletMetaProps = _propTypes.default.shape({
20
20
  enableLocale: _propTypes.default.bool,
21
21
  navigation: _propTypes.default.arrayOf(_propTypes.default.shape({
22
22
  title: _propTypes.default.oneOfType([_propTypes.default.string, _propTypes.default.object]),
23
- link: _propTypes.default.string,
23
+ link: _propTypes.default.oneOfType([_propTypes.default.string, _propTypes.default.object]),
24
24
  icon: _propTypes.default.string,
25
25
  items: _propTypes.default.arrayOf(_propTypes.default.shape({
26
26
  title: _propTypes.default.oneOfType([_propTypes.default.string, _propTypes.default.object]),
27
- link: _propTypes.default.string
27
+ link: _propTypes.default.oneOfType([_propTypes.default.string, _propTypes.default.object])
28
28
  }))
29
29
  }))
30
30
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocklet/ui-react",
3
- "version": "2.4.24",
3
+ "version": "2.4.27",
4
4
  "description": "Some useful front-end web components that can be used in Blocklets.",
5
5
  "keywords": [
6
6
  "react",
@@ -30,8 +30,8 @@
30
30
  "url": "https://github.com/ArcBlock/ux/issues"
31
31
  },
32
32
  "dependencies": {
33
- "@arcblock/did-connect": "^2.4.24",
34
- "@arcblock/ux": "^2.4.24",
33
+ "@arcblock/did-connect": "^2.4.27",
34
+ "@arcblock/ux": "^2.4.27",
35
35
  "@emotion/react": "^11.10.0",
36
36
  "@emotion/styled": "^11.10.0",
37
37
  "@iconify/iconify": "^2.2.1",
@@ -53,5 +53,5 @@
53
53
  "eslint-plugin-react-hooks": "^4.6.0",
54
54
  "jest": "^28.1.3"
55
55
  },
56
- "gitHead": "d82c2bdd64a3a3155312fd91e6e7b40293125ad3"
56
+ "gitHead": "89ac06d01dc874b1248e553f3563d57c36ea6647"
57
57
  }
@@ -1,42 +1,24 @@
1
- import { useMemo, useContext } from 'react';
1
+ /* eslint-disable no-shadow */
2
+ import { useMemo, useLayoutEffect, useContext } from 'react';
3
+ import PropTypes from 'prop-types';
2
4
  import { SessionContext } from '@arcblock/did-connect/lib/Session';
3
5
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
4
6
  import UxDashboard from '@arcblock/ux/lib/Layout/dashboard';
5
7
  import DidAddress from '@arcblock/did-connect/lib/Address';
6
8
  import DidAvatar from '@arcblock/did-connect/lib/Avatar';
7
- import { blockletMetaProps } from '../types';
8
- import { mapRecursive, flatRecursive, matchPaths, filterRecursive } from '../utils';
9
- import { publicPath, formatBlockletInfo, getLocalizedNavigation } from '../blocklets';
10
-
11
- /**
12
- * 根据 role 筛选 nav, 规则:
13
- * - 如果是枝结点, 必须至少存在一个符合条件的子结点
14
- * - role 未定义, 符合条件
15
- * - role 定义且包括当前的 userRole, 符合条件
16
- *
17
- * @param {object[]} nav 导航菜单数据
18
- * @param {string} userRole 当前用户 role
19
- * @returns 符合 role 权限的导航菜单数据
20
- */
21
- function filterNavByRole(nav, userRole) {
22
- return filterRecursive(
23
- nav,
24
- (item, context) => {
25
- const isRoleMatched = !item.role || (userRole && item.role.includes(userRole));
26
- if (!context.isLeaf) {
27
- return isRoleMatched && context.filteredChildren?.length;
28
- }
29
- return isRoleMatched;
30
- },
31
- 'items'
32
- );
33
- }
9
+ import { blockletMetaProps, sessionManagerProps } from '../types';
10
+ import { mapRecursive, flatRecursive, matchPaths } from '../utils';
11
+ import { publicPath, formatBlockletInfo, getLocalizedNavigation, filterNavByRole } from '../blocklets';
12
+ import HeaderAddons from '../common/header-addons';
34
13
 
35
14
  /**
36
15
  * 专门用于 (composable) blocklet 的 Dashboard 组件, 解析 blocklet meta 中 section 为 dashboard 的 navigation 数据, 渲染一个 UX Dashboard
37
16
  */
38
- function Dashboard({ meta, ...rest }) {
17
+ // eslint-disable-next-line no-shadow
18
+ function Dashboard({ meta, fallbackUrl, invalidPathFallback, headerAddons, sessionManagerProps, ...rest }) {
39
19
  const sessionCtx = useContext(SessionContext);
20
+ const user = sessionCtx?.session?.user;
21
+ const userRole = user?.role;
40
22
  const { locale } = useLocaleContext() || {};
41
23
  const blocklet = Object.assign({}, window.blocklet, meta);
42
24
  const formattedBlocklet = useMemo(() => {
@@ -47,34 +29,64 @@ function Dashboard({ meta, ...rest }) {
47
29
  return blocklet;
48
30
  }
49
31
  }, [blocklet]);
32
+ const { localizedNav, flattened, matchedIndex } = useMemo(() => {
33
+ let localizedNav = getLocalizedNavigation(formattedBlocklet?.navigation?.dashboard, locale) || [];
34
+ // 根据 role 筛选 nav 数据
35
+ localizedNav = filterNavByRole(localizedNav, userRole);
36
+ // 将 nav 数据处理成 ux dashboard 需要的格式
37
+ localizedNav = mapRecursive(
38
+ localizedNav,
39
+ (item) => ({
40
+ title: item.title,
41
+ url: item.link,
42
+ icon: item.icon ? <span className="iconify" data-icon={item.icon} /> : null,
43
+ // https://github.com/ArcBlock/ux/issues/755#issuecomment-1208692620
44
+ external: true,
45
+ children: item.items,
46
+ }),
47
+ 'items'
48
+ );
49
+ // 展平后使用 matchPaths 检测 link#active 状态
50
+ const flattened = flatRecursive(localizedNav).filter((item) => !!item.url);
51
+ const matchedIndex = matchPaths(flattened.map((item) => item.url));
52
+ if (flattened?.length) {
53
+ flattened[matchedIndex === -1 ? 0 : matchedIndex].active = true;
54
+ }
55
+ return { localizedNav, flattened, matchedIndex };
56
+ }, [formattedBlocklet, locale, userRole]);
57
+
58
+ // 页面初始化时, 如果当前用户没有权限访问任何导航菜单 (比如登录时未提供 VC 导致无权限), 则跳转到 fallbackUrl
59
+ // 未认证 (user 为空) 时不做处理, 这种情况的页面跳转逻辑一般由应用自行处理
60
+ useLayoutEffect(() => {
61
+ if (!!user && !flattened?.length && fallbackUrl) {
62
+ window.location.href = fallbackUrl;
63
+ }
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, [fallbackUrl]);
66
+
67
+ // 导航菜单变动且存在可用菜单但无匹配项时 (如切换 passport), 跳转到首个菜单项
68
+ useLayoutEffect(() => {
69
+ if (!!user && !!flattened?.length && matchedIndex === -1) {
70
+ if (invalidPathFallback) {
71
+ invalidPathFallback();
72
+ } else {
73
+ window.location.href = flattened[0]?.url || publicPath;
74
+ }
75
+ }
76
+ // eslint-disable-next-line react-hooks/exhaustive-deps
77
+ }, [invalidPathFallback]);
50
78
 
51
79
  if (!blocklet.appName) {
52
80
  return null;
53
81
  }
54
-
55
82
  const { did, appLogo, appName } = formattedBlocklet;
56
- let localizedNav = getLocalizedNavigation(formattedBlocklet?.navigation?.dashboard, locale) || [];
57
- // 根据 role 筛选 nav 数据
58
- localizedNav = filterNavByRole(localizedNav, sessionCtx?.session?.user?.role);
59
- // 将 nav 数据处理成 ux dashboard 需要的格式
60
- localizedNav = mapRecursive(
61
- localizedNav,
62
- (item) => ({
63
- title: item.title,
64
- url: item.link,
65
- icon: item.icon ? <span className="iconify" data-icon={item.icon} /> : null,
66
- // https://github.com/ArcBlock/ux/issues/755#issuecomment-1208692620
67
- external: true,
68
- children: item.items,
69
- }),
70
- 'items'
83
+ const _headerAddons = (
84
+ <HeaderAddons
85
+ formattedBlocklet={formattedBlocklet}
86
+ addons={headerAddons}
87
+ sessionManagerProps={sessionManagerProps}
88
+ />
71
89
  );
72
- // 展平后使用 matchPaths 检测 link#active 状态
73
- const flattened = flatRecursive(localizedNav).filter((item) => !!item.url);
74
- const matchedIndex = matchPaths(flattened.map((item) => item.url));
75
- if (flattened?.length) {
76
- flattened[matchedIndex === -1 ? 0 : matchedIndex].active = true;
77
- }
78
90
 
79
91
  return (
80
92
  <UxDashboard
@@ -100,6 +112,7 @@ function Dashboard({ meta, ...rest }) {
100
112
  <img src={appLogo} alt="logo" />
101
113
  </a>
102
114
  ),
115
+ addons: _headerAddons,
103
116
  ...rest.headerProps,
104
117
  }}
105
118
  links={localizedNav}
@@ -109,10 +122,26 @@ function Dashboard({ meta, ...rest }) {
109
122
 
110
123
  Dashboard.propTypes = {
111
124
  meta: blockletMetaProps,
125
+ // 如果当前用户没有权限访问任何导航菜单, 则自动跳转到 fallbackUrl, 默认值为 publicPath, 设置为 null 表示禁用自动跳转
126
+ fallbackUrl: PropTypes.string,
127
+ // 当前路径未匹配任何 nav links 时的 fallback, 默认行为跳转到首个可用的 nav link
128
+ invalidPathFallback: PropTypes.func,
129
+ headerAddons: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
130
+ sessionManagerProps,
112
131
  };
113
132
 
114
133
  Dashboard.defaultProps = {
115
134
  meta: {},
135
+ fallbackUrl: publicPath,
136
+ invalidPathFallback: null,
137
+ headerAddons: undefined,
138
+ sessionManagerProps: {
139
+ showRole: true,
140
+ // dashboard 默认退出登录行为: 跳转到 (root) blocklet 首页
141
+ onLogout: () => {
142
+ window.location.href = publicPath;
143
+ },
144
+ },
116
145
  };
117
146
 
118
147
  export default Dashboard;
@@ -1,15 +1,10 @@
1
- import { useContext, useMemo, createElement } from 'react';
2
- // FIXME: 直接从 react 中 import Fragment 可能会在 vite 下出错,先暂时从 react/jsx-runtime 导入 Fragment 来跳过这个问题
3
- import { Fragment } from 'react/jsx-runtime';
1
+ import { useMemo } from 'react';
4
2
  import PropTypes from 'prop-types';
5
3
  import { withErrorBoundary } from 'react-error-boundary';
6
4
  import { ErrorFallback } from '@arcblock/ux/lib/ErrorBoundary';
7
5
  import { styled } from '@arcblock/ux/lib/Theme';
8
6
  import { ResponsiveHeader } from '@arcblock/ux/lib/Header';
9
7
  import NavMenu from '@arcblock/ux/lib/NavMenu';
10
- import { SessionContext } from '@arcblock/did-connect/lib/Session';
11
- import SessionManager from '@arcblock/did-connect/lib/SessionManager';
12
- import LocaleSelector from '@arcblock/ux/lib/Locale/selector';
13
8
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
14
9
  import DidAddress from '@arcblock/did-connect/lib/Address';
15
10
  import DidAvatar from '@arcblock/did-connect/lib/Avatar';
@@ -19,6 +14,7 @@ import OverridableThemeProvider from '../common/overridable-theme-provider';
19
14
  import { blockletMetaProps, sessionManagerProps } from '../types';
20
15
  import { mapRecursive, flatRecursive, matchPaths } from '../utils';
21
16
  import { publicPath, formatBlockletInfo, getLocalizedNavigation } from '../blocklets';
17
+ import HeaderAddons from '../common/header-addons';
22
18
 
23
19
  // blocklet meta 中的 navigation 数据 => NavMenu 组件的 items
24
20
  const parseNavigation = (navigation) => {
@@ -65,7 +61,6 @@ const parseNavigation = (navigation) => {
65
61
  */
66
62
  // eslint-disable-next-line no-shadow
67
63
  function Header({ meta, addons, sessionManagerProps, homeLink, theme: themeOverrides, ...rest }) {
68
- const sessionInfo = useContext(SessionContext);
69
64
  const { locale } = useLocaleContext() || {};
70
65
  const blocklet = Object.assign({}, window.blocklet, meta);
71
66
  const formattedBlocklet = useMemo(() => {
@@ -80,41 +75,15 @@ function Header({ meta, addons, sessionManagerProps, homeLink, theme: themeOverr
80
75
  if (!blocklet.appName) {
81
76
  return null;
82
77
  }
83
- const { did, appLogo, appName, theme, enableConnect = true, enableLocale = true } = formattedBlocklet;
78
+ const { did, appLogo, appName, theme } = formattedBlocklet;
84
79
  const navigation = getLocalizedNavigation(formattedBlocklet?.navigation?.header, locale);
85
80
  const parsedNavigation = parseNavigation(navigation);
86
81
  const { navItems, activeId } = parsedNavigation;
87
82
 
88
- const renderAddons = () => {
89
- // 不关心内置的 session manager locale selector, 直接覆盖 UX Header addons
90
- if (addons && typeof addons !== 'function') {
91
- return Array.isArray(addons) ? addons : [addons];
92
- }
93
- let addonsArray = [];
94
- // 启用了多语言并且检测到了 locale context
95
- if (enableLocale && locale) {
96
- addonsArray.push(<LocaleSelector key="locale-selector" showText={false} />);
97
- }
98
- // 启用了连接钱包并且检测到了 session context
99
- if (enableConnect && sessionInfo) {
100
- addonsArray.push(<SessionManager key="session-manager" session={sessionInfo.session} {...sessionManagerProps} />);
101
- }
102
- // 在内置 addons 基础上定制 addons
103
- if (typeof addons === 'function') {
104
- addonsArray =
105
- addons(addonsArray, {
106
- navigation: parsedNavigation,
107
- }) || [];
108
- }
109
- return addonsArray;
110
- };
111
-
112
- const renderedAddons = renderAddons();
113
-
114
- const addonList = createElement(
115
- Fragment,
116
- null,
117
- ...(Array.isArray(renderedAddons) ? renderedAddons : [renderedAddons])
83
+ const _addons =
84
+ typeof addons === 'function' ? (builtInAddons) => addons(builtInAddons, { navigation: parsedNavigation }) : addons;
85
+ const headerAddons = (
86
+ <HeaderAddons formattedBlocklet={formattedBlocklet} addons={_addons} sessionManagerProps={sessionManagerProps} />
118
87
  );
119
88
 
120
89
  return (
@@ -138,7 +107,7 @@ function Header({ meta, addons, sessionManagerProps, homeLink, theme: themeOverr
138
107
  </DidAddress>
139
108
  ) : null
140
109
  }
141
- addons={addonList}
110
+ addons={headerAddons}
142
111
  {...rest}
143
112
  $bgcolor={theme?.background?.header}>
144
113
  {/* blocklet.yml 没有配置 navigation 时, 则为 children 传入 null, 此时 ResponsiveHeader 会渲染普通的不带 menu 的 Header */}
package/src/blocklets.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mapRecursive, isUrl } from './utils';
1
+ import { mapRecursive, filterRecursive, isUrl } from './utils';
2
2
 
3
3
  export const publicPath = window?.blocklet?.groupPrefix || window?.blocklet?.prefix || '/';
4
4
 
@@ -61,7 +61,8 @@ export const getLocalizedNavigation = (navigation, locale = 'en') => {
61
61
  return {
62
62
  ...item,
63
63
  title: getTitle(item.title, locale),
64
- link: getLink(item.link, locale),
64
+ // 仅对叶结点进行处理
65
+ link: !item.items?.length ? getLink(item.link, locale) : item.link,
65
66
  };
66
67
  },
67
68
  'items'
@@ -105,6 +106,8 @@ export const parseNavigation = (navigation) => {
105
106
  bottom: [],
106
107
  // 对应 dashboard#sidenav 导航
107
108
  dashboard: [],
109
+ // session manager menus
110
+ sessionManager: [],
108
111
  };
109
112
 
110
113
  // 对 navigation 顶层元素按 section 分组
@@ -140,3 +143,27 @@ export const formatBlockletInfo = (blockletInfo) => {
140
143
  formatted.navigation = parseNavigation(formatted.navigation);
141
144
  return formatted;
142
145
  };
146
+
147
+ /**
148
+ * 根据 role 筛选 nav, 规则:
149
+ * - 如果是枝结点, 必须至少存在一个符合条件的子结点
150
+ * - role 未定义, 符合条件
151
+ * - role 定义且包括当前的 userRole, 符合条件
152
+ *
153
+ * @param {object[]} nav 导航菜单数据
154
+ * @param {string} userRole 当前用户 role
155
+ * @returns 符合 role 权限的导航菜单数据
156
+ */
157
+ export const filterNavByRole = (nav, userRole) => {
158
+ return filterRecursive(
159
+ nav,
160
+ (item, context) => {
161
+ const isRoleMatched = !item.role || (userRole && item.role.includes(userRole));
162
+ if (!context.isLeaf) {
163
+ return isRoleMatched && context.filteredChildren?.length;
164
+ }
165
+ return isRoleMatched;
166
+ },
167
+ 'items'
168
+ );
169
+ };
@@ -0,0 +1,88 @@
1
+ import { useContext, createElement } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ // FIXME: 直接从 react 中 import Fragment 可能会在 vite 下出错,先暂时从 react/jsx-runtime 导入 Fragment 来跳过这个问题
4
+ import { Fragment } from 'react/jsx-runtime';
5
+ import { SessionContext } from '@arcblock/did-connect/lib/Session';
6
+ import SessionManager from '@arcblock/did-connect/lib/SessionManager';
7
+ import LocaleSelector from '@arcblock/ux/lib/Locale/selector';
8
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
9
+ import { sessionManagerProps } from '../types';
10
+ import { getLocalizedNavigation, filterNavByRole } from '../blocklets';
11
+
12
+ // eslint-disable-next-line no-shadow
13
+ export default function HeaderAddons({ formattedBlocklet, addons, sessionManagerProps }) {
14
+ const sessionCtx = useContext(SessionContext);
15
+ const { locale } = useLocaleContext() || {};
16
+ const { enableConnect = true, enableLocale = true } = formattedBlocklet;
17
+ const authenticated = !!sessionCtx?.session?.user;
18
+ let localizedNav = getLocalizedNavigation(formattedBlocklet?.navigation?.sessionManager, locale) || [];
19
+ // 根据 role 筛选 nav 数据
20
+ localizedNav = filterNavByRole(localizedNav, sessionCtx?.session?.user?.role);
21
+
22
+ const renderAddons = () => {
23
+ // 不关心内置的 session manager 和 locale selector, 直接覆盖 UX Header 的 addons
24
+ if (addons && typeof addons !== 'function') {
25
+ return Array.isArray(addons) ? addons : [addons];
26
+ }
27
+ let addonsArray = [];
28
+ // 启用了多语言并且检测到了 locale context
29
+ if (enableLocale && locale) {
30
+ addonsArray.push(<LocaleSelector key="locale-selector" showText={false} />);
31
+ }
32
+ // 启用了连接钱包并且检测到了 session context
33
+ if (enableConnect && sessionCtx) {
34
+ const menu = [];
35
+ if (authenticated && localizedNav?.length) {
36
+ localizedNav.forEach((item) => {
37
+ menu.push({
38
+ label: item.title,
39
+ icon: item.icon ? (
40
+ <span className="iconify" data-icon={item.icon} data-height="24" style={{ marginRight: 16 }} />
41
+ ) : null,
42
+ component: 'a',
43
+ href: item.link,
44
+ key: item.link,
45
+ });
46
+ });
47
+ }
48
+ addonsArray.push(
49
+ <SessionManager
50
+ key="session-manager"
51
+ session={sessionCtx.session}
52
+ locale={locale}
53
+ menu={menu}
54
+ {...sessionManagerProps}
55
+ />
56
+ );
57
+ }
58
+ // 在内置 addons 基础上定制 addons
59
+ if (typeof addons === 'function') {
60
+ addonsArray = addons(addonsArray) || [];
61
+ }
62
+ return addonsArray;
63
+ };
64
+
65
+ const renderedAddons = renderAddons();
66
+ const addonList = createElement(
67
+ Fragment,
68
+ null,
69
+ ...(Array.isArray(renderedAddons) ? renderedAddons : [renderedAddons])
70
+ );
71
+ return addonList;
72
+ }
73
+
74
+ HeaderAddons.propTypes = {
75
+ formattedBlocklet: PropTypes.object.isRequired,
76
+ // 需要考虑 定制的 addons 与内置的 连接钱包/选择语言 addons 共存的情况
77
+ // - PropTypes.func: 可以把自定义 addons 插在 session-manager 或 locale-selector (如果存在的话) 前/中/后
78
+ // - PropTypes.node: 将 addons 原样传给 UX Header 组件
79
+ addons: PropTypes.oneOfType([PropTypes.func, PropTypes.node]),
80
+ sessionManagerProps,
81
+ };
82
+
83
+ HeaderAddons.defaultProps = {
84
+ addons: null,
85
+ sessionManagerProps: {
86
+ showRole: true,
87
+ },
88
+ };
package/src/types.js CHANGED
@@ -12,12 +12,12 @@ export const blockletMetaProps = PropTypes.shape({
12
12
  navigation: PropTypes.arrayOf(
13
13
  PropTypes.shape({
14
14
  title: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
15
- link: PropTypes.string,
15
+ link: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
16
16
  icon: PropTypes.string,
17
17
  items: PropTypes.arrayOf(
18
18
  PropTypes.shape({
19
19
  title: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
20
- link: PropTypes.string,
20
+ link: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
21
21
  })
22
22
  ),
23
23
  })