@atlaskit/link-datasource 1.12.4 → 1.13.1
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/CHANGELOG.md +12 -0
- package/dist/cjs/analytics/constants.js +1 -1
- package/dist/cjs/ui/jira-issues-modal/basic-filters/hooks/useFilterOptions.js +34 -12
- package/dist/cjs/ui/jira-issues-modal/basic-filters/ui/async-popup-select/dropdownIndicator.js +39 -0
- package/dist/cjs/ui/jira-issues-modal/basic-filters/ui/async-popup-select/index.js +51 -20
- package/dist/cjs/ui/jira-issues-modal/basic-filters/ui/async-popup-select/trigger.js +2 -0
- package/dist/cjs/ui/jira-issues-modal/basic-filters/ui/index.js +1 -0
- package/dist/cjs/ui/jira-issues-modal/basic-filters/utils/isClauseTooComplex.js +93 -0
- package/dist/cjs/ui/jira-issues-modal/basic-filters/utils/isQueryTooComplex.js +146 -0
- package/dist/cjs/ui/jira-issues-modal/jira-search-container/buildJQL.js +3 -2
- package/dist/cjs/ui/jira-issues-modal/jira-search-container/index.js +21 -8
- package/dist/cjs/ui/jira-issues-modal/jira-search-container/messages.js +5 -0
- package/dist/cjs/ui/jira-issues-modal/mode-switcher/index.js +28 -16
- package/dist/es2019/analytics/constants.js +1 -1
- package/dist/es2019/ui/jira-issues-modal/basic-filters/hooks/useFilterOptions.js +14 -2
- package/dist/es2019/ui/jira-issues-modal/basic-filters/ui/async-popup-select/dropdownIndicator.js +34 -0
- package/dist/es2019/ui/jira-issues-modal/basic-filters/ui/async-popup-select/index.js +25 -10
- package/dist/es2019/ui/jira-issues-modal/basic-filters/ui/async-popup-select/trigger.js +2 -0
- package/dist/es2019/ui/jira-issues-modal/basic-filters/ui/index.js +1 -0
- package/dist/es2019/ui/jira-issues-modal/basic-filters/utils/isClauseTooComplex.js +77 -0
- package/dist/es2019/ui/jira-issues-modal/basic-filters/utils/isQueryTooComplex.js +105 -0
- package/dist/es2019/ui/jira-issues-modal/jira-search-container/buildJQL.js +2 -1
- package/dist/es2019/ui/jira-issues-modal/jira-search-container/index.js +13 -3
- package/dist/es2019/ui/jira-issues-modal/jira-search-container/messages.js +5 -0
- package/dist/es2019/ui/jira-issues-modal/mode-switcher/index.js +19 -9
- package/dist/esm/analytics/constants.js +1 -1
- package/dist/esm/ui/jira-issues-modal/basic-filters/hooks/useFilterOptions.js +35 -13
- package/dist/esm/ui/jira-issues-modal/basic-filters/ui/async-popup-select/dropdownIndicator.js +32 -0
- package/dist/esm/ui/jira-issues-modal/basic-filters/ui/async-popup-select/index.js +52 -21
- package/dist/esm/ui/jira-issues-modal/basic-filters/ui/async-popup-select/trigger.js +2 -0
- package/dist/esm/ui/jira-issues-modal/basic-filters/ui/index.js +1 -0
- package/dist/esm/ui/jira-issues-modal/basic-filters/utils/isClauseTooComplex.js +86 -0
- package/dist/esm/ui/jira-issues-modal/basic-filters/utils/isQueryTooComplex.js +140 -0
- package/dist/esm/ui/jira-issues-modal/jira-search-container/buildJQL.js +2 -1
- package/dist/esm/ui/jira-issues-modal/jira-search-container/index.js +21 -8
- package/dist/esm/ui/jira-issues-modal/jira-search-container/messages.js +5 -0
- package/dist/esm/ui/jira-issues-modal/mode-switcher/index.js +29 -17
- package/dist/types/ui/jira-issues-modal/basic-filters/ui/async-popup-select/dropdownIndicator.d.ts +5 -0
- package/dist/types/ui/jira-issues-modal/basic-filters/ui/async-popup-select/index.d.ts +2 -1
- package/dist/types/ui/jira-issues-modal/basic-filters/ui/async-popup-select/trigger.d.ts +1 -0
- package/dist/types/ui/jira-issues-modal/basic-filters/utils/isClauseTooComplex.d.ts +2 -0
- package/dist/types/ui/jira-issues-modal/basic-filters/utils/isQueryTooComplex.d.ts +1 -0
- package/dist/types/ui/jira-issues-modal/jira-search-container/buildJQL.d.ts +1 -0
- package/dist/types/ui/jira-issues-modal/jira-search-container/index.d.ts +1 -0
- package/dist/types/ui/jira-issues-modal/jira-search-container/messages.d.ts +5 -0
- package/dist/types/ui/jira-issues-modal/mode-switcher/index.d.ts +2 -0
- package/dist/types-ts4.5/ui/jira-issues-modal/basic-filters/ui/async-popup-select/dropdownIndicator.d.ts +5 -0
- package/dist/types-ts4.5/ui/jira-issues-modal/basic-filters/ui/async-popup-select/index.d.ts +2 -1
- package/dist/types-ts4.5/ui/jira-issues-modal/basic-filters/ui/async-popup-select/trigger.d.ts +1 -0
- package/dist/types-ts4.5/ui/jira-issues-modal/basic-filters/utils/isClauseTooComplex.d.ts +2 -0
- package/dist/types-ts4.5/ui/jira-issues-modal/basic-filters/utils/isQueryTooComplex.d.ts +1 -0
- package/dist/types-ts4.5/ui/jira-issues-modal/jira-search-container/buildJQL.d.ts +1 -0
- package/dist/types-ts4.5/ui/jira-issues-modal/jira-search-container/index.d.ts +1 -0
- package/dist/types-ts4.5/ui/jira-issues-modal/jira-search-container/messages.d.ts +5 -0
- package/dist/types-ts4.5/ui/jira-issues-modal/mode-switcher/index.d.ts +2 -0
- package/package.json +1 -2
- package/dist/cjs/ui/jira-issues-modal/basic-filters/hooks/useIsComplexQuery.js +0 -12
- package/dist/es2019/ui/jira-issues-modal/basic-filters/hooks/useIsComplexQuery.js +0 -6
- package/dist/esm/ui/jira-issues-modal/basic-filters/hooks/useIsComplexQuery.js +0 -6
- package/dist/types/ui/jira-issues-modal/basic-filters/hooks/useIsComplexQuery.d.ts +0 -3
- package/dist/types-ts4.5/ui/jira-issues-modal/basic-filters/hooks/useIsComplexQuery.d.ts +0 -3
|
@@ -5,7 +5,7 @@ var _typeof = require("@babel/runtime/helpers/typeof");
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", {
|
|
6
6
|
value: true
|
|
7
7
|
});
|
|
8
|
-
exports.JiraSearchContainer = void 0;
|
|
8
|
+
exports.JiraSearchContainer = exports.ALLOWED_ORDER_BY_KEYS = void 0;
|
|
9
9
|
var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray"));
|
|
10
10
|
var _react = _interopRequireWildcard(require("react"));
|
|
11
11
|
var _react2 = require("@emotion/react");
|
|
@@ -13,6 +13,7 @@ var _reactIntlNext = require("react-intl-next");
|
|
|
13
13
|
var _platformFeatureFlags = require("@atlaskit/platform-feature-flags");
|
|
14
14
|
var _analytics = require("../../../analytics");
|
|
15
15
|
var _basicFilters = require("../basic-filters");
|
|
16
|
+
var _isQueryTooComplex = require("../basic-filters/utils/isQueryTooComplex");
|
|
16
17
|
var _basicSearchInput = require("../basic-search-input");
|
|
17
18
|
var _jqlEditor = require("../jql-editor");
|
|
18
19
|
var _modeSwitcher = require("../mode-switcher");
|
|
@@ -29,6 +30,7 @@ var inputContainerStyles = (0, _react2.css)({
|
|
|
29
30
|
minHeight: '60px'
|
|
30
31
|
});
|
|
31
32
|
var DEFAULT_JQL_QUERY = 'created >= -30d order by created DESC';
|
|
33
|
+
var ALLOWED_ORDER_BY_KEYS = exports.ALLOWED_ORDER_BY_KEYS = ['key', 'summary', 'assignee', 'status', 'created'];
|
|
32
34
|
var JiraSearchMethodSwitcher = _modeSwitcher.ModeSwitcher;
|
|
33
35
|
var JiraSearchContainer = exports.JiraSearchContainer = function JiraSearchContainer(props) {
|
|
34
36
|
var isSearching = props.isSearching,
|
|
@@ -53,14 +55,18 @@ var JiraSearchContainer = exports.JiraSearchContainer = function JiraSearchConta
|
|
|
53
55
|
_useState6 = (0, _slicedToArray2.default)(_useState5, 2),
|
|
54
56
|
jql = _useState6[0],
|
|
55
57
|
setJql = _useState6[1];
|
|
56
|
-
var _useState7 = (0, _react.useState)(),
|
|
58
|
+
var _useState7 = (0, _react.useState)(false),
|
|
57
59
|
_useState8 = (0, _slicedToArray2.default)(_useState7, 2),
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
isComplexQuery = _useState8[0],
|
|
61
|
+
setIsComplexQuery = _useState8[1];
|
|
60
62
|
var _useState9 = (0, _react.useState)(),
|
|
61
63
|
_useState10 = (0, _slicedToArray2.default)(_useState9, 2),
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
orderKey = _useState10[0],
|
|
65
|
+
setOrderKey = _useState10[1];
|
|
66
|
+
var _useState11 = (0, _react.useState)(),
|
|
67
|
+
_useState12 = (0, _slicedToArray2.default)(_useState11, 2),
|
|
68
|
+
orderDirection = _useState12[0],
|
|
69
|
+
setOrderDirection = _useState12[1];
|
|
64
70
|
var _useDatasourceAnalyti = (0, _analytics.useDatasourceAnalyticsEvents)(),
|
|
65
71
|
fireEvent = _useDatasourceAnalyti.fireEvent;
|
|
66
72
|
var onSearchMethodChange = (0, _react.useCallback)(function (searchMethod) {
|
|
@@ -87,7 +93,7 @@ var JiraSearchContainer = exports.JiraSearchContainer = function JiraSearchConta
|
|
|
87
93
|
var order = hasOrder ? (_fragments$at3 = fragments.at(-1)) === null || _fragments$at3 === void 0 ? void 0 : _fragments$at3.split(' ').at(-1) : undefined;
|
|
88
94
|
|
|
89
95
|
// TODO: confirm if these are the only order keys we want to preserve - existing whiteboard logic
|
|
90
|
-
if (key &&
|
|
96
|
+
if (key && ALLOWED_ORDER_BY_KEYS.includes(key)) {
|
|
91
97
|
setOrderKey(key);
|
|
92
98
|
setOrderDirection(order);
|
|
93
99
|
}
|
|
@@ -97,6 +103,7 @@ var JiraSearchContainer = exports.JiraSearchContainer = function JiraSearchConta
|
|
|
97
103
|
onSearch({
|
|
98
104
|
jql: jql
|
|
99
105
|
}, currentSearchMethod);
|
|
106
|
+
setIsComplexQuery((0, _isQueryTooComplex.isQueryTooComplex)(jql));
|
|
100
107
|
if (currentSearchMethod === 'basic') {
|
|
101
108
|
fireEvent('ui.form.submitted.basicSearch', {});
|
|
102
109
|
} else if (currentSearchMethod === 'jql') {
|
|
@@ -109,6 +116,10 @@ var JiraSearchContainer = exports.JiraSearchContainer = function JiraSearchConta
|
|
|
109
116
|
}
|
|
110
117
|
return false;
|
|
111
118
|
}, []);
|
|
119
|
+
(0, _react.useEffect)(function () {
|
|
120
|
+
setIsComplexQuery((0, _isQueryTooComplex.isQueryTooComplex)(jql));
|
|
121
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
122
|
+
}, []);
|
|
112
123
|
return (0, _react2.jsx)("div", {
|
|
113
124
|
css: inputContainerStyles
|
|
114
125
|
}, currentSearchMethod === 'basic' && (0, _react2.jsx)(_react.default.Fragment, null, (0, _react2.jsx)(_basicSearchInput.BasicSearchInput, {
|
|
@@ -133,7 +144,9 @@ var JiraSearchContainer = exports.JiraSearchContainer = function JiraSearchConta
|
|
|
133
144
|
value: 'jql'
|
|
134
145
|
}, {
|
|
135
146
|
label: formatMessage(_messages.modeSwitcherMessages.basicTextSearchLabel),
|
|
136
|
-
value: 'basic'
|
|
147
|
+
value: 'basic',
|
|
148
|
+
disabled: isComplexQuery,
|
|
149
|
+
tooltipText: isComplexQuery ? formatMessage(_messages.modeSwitcherMessages.basicModeSwitchDisabledTooltipText) : ''
|
|
137
150
|
}]
|
|
138
151
|
}));
|
|
139
152
|
};
|
|
@@ -10,5 +10,10 @@ var modeSwitcherMessages = exports.modeSwitcherMessages = (0, _reactIntlNext.def
|
|
|
10
10
|
id: 'linkDataSource.jira-issues.configmodal.basicModeText',
|
|
11
11
|
description: 'Display text for basic text search toggle button',
|
|
12
12
|
defaultMessage: 'Basic'
|
|
13
|
+
},
|
|
14
|
+
basicModeSwitchDisabledTooltipText: {
|
|
15
|
+
id: 'linkDataSource.jira-issues.configmodal.basicModeSwitchDisabledTooltipText',
|
|
16
|
+
description: 'Display tooltip text when basic mode switch is disabled',
|
|
17
|
+
defaultMessage: "You can't switch to basic for this query."
|
|
13
18
|
}
|
|
14
19
|
});
|
|
@@ -5,9 +5,11 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
5
5
|
value: true
|
|
6
6
|
});
|
|
7
7
|
exports.ModeSwitcher = void 0;
|
|
8
|
+
var _extends2 = _interopRequireDefault(require("@babel/runtime/helpers/extends"));
|
|
8
9
|
var _react = _interopRequireDefault(require("react"));
|
|
9
10
|
var _react2 = require("@emotion/react");
|
|
10
11
|
var _colors = require("@atlaskit/theme/colors");
|
|
12
|
+
var _tooltip = _interopRequireDefault(require("@atlaskit/tooltip"));
|
|
11
13
|
/** @jsx jsx */
|
|
12
14
|
|
|
13
15
|
var modeSwitcherStyles = (0, _react2.css)({
|
|
@@ -52,8 +54,12 @@ var modeSwitcherLabelSelectedStyles = (0, _react2.css)({
|
|
|
52
54
|
}
|
|
53
55
|
});
|
|
54
56
|
var modeSwitcherLabelDisabledStyles = (0, _react2.css)({
|
|
57
|
+
color: "var(--ds-text-disabled, ".concat(_colors.N60, ")")
|
|
58
|
+
});
|
|
59
|
+
var modeSwitcherDisabledStyles = (0, _react2.css)({
|
|
55
60
|
':hover': {
|
|
56
|
-
cursor: 'not-allowed'
|
|
61
|
+
cursor: 'not-allowed',
|
|
62
|
+
background: 'transparent'
|
|
57
63
|
}
|
|
58
64
|
});
|
|
59
65
|
var compactModeSwitcherLabelStyles = (0, _react2.css)({
|
|
@@ -76,21 +82,27 @@ var ModeSwitcher = exports.ModeSwitcher = function ModeSwitcher(props) {
|
|
|
76
82
|
disabled: isDisabled
|
|
77
83
|
}, options.map(function (_ref) {
|
|
78
84
|
var value = _ref.value,
|
|
79
|
-
label = _ref.label
|
|
85
|
+
label = _ref.label,
|
|
86
|
+
isOptionDisabled = _ref.disabled,
|
|
87
|
+
tooltipText = _ref.tooltipText;
|
|
80
88
|
var isSelected = value === selectedOptionValue;
|
|
81
|
-
return (0, _react2.jsx)(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
89
|
+
return (0, _react2.jsx)(_tooltip.default, {
|
|
90
|
+
content: tooltipText
|
|
91
|
+
}, function (tooltipProps) {
|
|
92
|
+
return (0, _react2.jsx)("label", (0, _extends2.default)({}, tooltipProps, {
|
|
93
|
+
key: value,
|
|
94
|
+
css: [modeSwitcherLabelStyles, isCompact && compactModeSwitcherLabelStyles, isSelected && modeSwitcherLabelSelectedStyles, isDisabled && modeSwitcherDisabledStyles, isOptionDisabled && [modeSwitcherLabelDisabledStyles, modeSwitcherDisabledStyles]],
|
|
95
|
+
"data-testid": "mode-toggle-".concat(value)
|
|
96
|
+
}), label, (0, _react2.jsx)("input", {
|
|
97
|
+
"aria-checked": isSelected,
|
|
98
|
+
"aria-disabled": isOptionDisabled,
|
|
99
|
+
checked: isSelected,
|
|
100
|
+
css: modeInputStyles,
|
|
101
|
+
disabled: isOptionDisabled,
|
|
102
|
+
onChange: handleModeChange,
|
|
103
|
+
type: "radio",
|
|
104
|
+
value: value
|
|
105
|
+
}));
|
|
106
|
+
});
|
|
95
107
|
})) : null;
|
|
96
108
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useState } from 'react';
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
2
|
import { useBasicFilterAGG } from '../../../../services/useBasicFilterAGG';
|
|
3
3
|
import { mapFieldValuesToFilterOptions, mapFieldValuesToPageCursor, mapFieldValuesToTotalCount } from '../utils/transformers';
|
|
4
4
|
export const useFilterOptions = ({
|
|
@@ -9,6 +9,7 @@ export const useFilterOptions = ({
|
|
|
9
9
|
const [totalCount, setTotalCount] = useState(0);
|
|
10
10
|
const [status, setStatus] = useState('empty');
|
|
11
11
|
const [nextPageCursor, setNextPageCursor] = useState(undefined);
|
|
12
|
+
const initialData = useRef();
|
|
12
13
|
const {
|
|
13
14
|
getFieldValues
|
|
14
15
|
} = useBasicFilterAGG();
|
|
@@ -17,8 +18,12 @@ export const useFilterOptions = ({
|
|
|
17
18
|
searchString
|
|
18
19
|
} = {}) => {
|
|
19
20
|
setStatus('loading');
|
|
21
|
+
const isRequestLikeInitialSearch = !pageCursor && !searchString;
|
|
22
|
+
const {
|
|
23
|
+
current: initialResponseData
|
|
24
|
+
} = initialData;
|
|
20
25
|
try {
|
|
21
|
-
const response = await getFieldValues({
|
|
26
|
+
const response = isRequestLikeInitialSearch && initialResponseData ? initialResponseData : await getFieldValues({
|
|
22
27
|
cloudId,
|
|
23
28
|
jql: '',
|
|
24
29
|
jqlTerm: filterType,
|
|
@@ -32,6 +37,13 @@ export const useFilterOptions = ({
|
|
|
32
37
|
const isNewSearch = !pageCursor;
|
|
33
38
|
if (isNewSearch) {
|
|
34
39
|
setFilterOptions(mapFieldValuesToFilterOptions(response));
|
|
40
|
+
if (isRequestLikeInitialSearch) {
|
|
41
|
+
/**
|
|
42
|
+
* The initial dataset is used in couple of paths, eg: when a user searches and clears the search text.
|
|
43
|
+
* During these times, we dont want to fetch data again and again, hence a mini cache setup to store and provide the initial dataset
|
|
44
|
+
*/
|
|
45
|
+
initialData.current = response;
|
|
46
|
+
}
|
|
35
47
|
} else {
|
|
36
48
|
setFilterOptions([...filterOptions, ...mapFieldValuesToFilterOptions(response)]);
|
|
37
49
|
}
|
package/dist/es2019/ui/jira-issues-modal/basic-filters/ui/async-popup-select/dropdownIndicator.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import CloseIcon from '@atlaskit/icon/glyph/cross-circle';
|
|
3
|
+
import SearchIcon from '@atlaskit/icon/glyph/search';
|
|
4
|
+
import { Box, xcss } from '@atlaskit/primitives';
|
|
5
|
+
import { components } from '@atlaskit/select';
|
|
6
|
+
const customDropdownIndicatorStyles = xcss({
|
|
7
|
+
display: 'flex',
|
|
8
|
+
cursor: 'pointer',
|
|
9
|
+
justifyContent: 'center',
|
|
10
|
+
width: "var(--ds-space-400, 32px)"
|
|
11
|
+
});
|
|
12
|
+
const CustomDropdownIndicator = props => {
|
|
13
|
+
const {
|
|
14
|
+
selectProps
|
|
15
|
+
} = props;
|
|
16
|
+
return /*#__PURE__*/React.createElement(components.DropdownIndicator, props, /*#__PURE__*/React.createElement(Box, {
|
|
17
|
+
xcss: customDropdownIndicatorStyles,
|
|
18
|
+
onClick: () => {
|
|
19
|
+
if (selectProps.inputValue) {
|
|
20
|
+
selectProps.onInputChange && selectProps.onInputChange('', {
|
|
21
|
+
action: 'input-change',
|
|
22
|
+
prevInputValue: selectProps.inputValue
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}, selectProps.inputValue ? /*#__PURE__*/React.createElement(CloseIcon, {
|
|
27
|
+
size: "small",
|
|
28
|
+
label: ""
|
|
29
|
+
}) : /*#__PURE__*/React.createElement(SearchIcon, {
|
|
30
|
+
size: "small",
|
|
31
|
+
label: ""
|
|
32
|
+
})));
|
|
33
|
+
};
|
|
34
|
+
export default CustomDropdownIndicator;
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import _extends from "@babel/runtime/helpers/extends";
|
|
2
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import debounce from 'lodash/debounce';
|
|
3
4
|
import { useIntl } from 'react-intl-next';
|
|
4
5
|
import { CheckboxOption, PopupSelect } from '@atlaskit/select';
|
|
5
6
|
import { useFilterOptions } from '../../hooks/useFilterOptions';
|
|
6
7
|
import CustomControl from './control';
|
|
8
|
+
import CustomDropdownIndicator from './dropdownIndicator';
|
|
7
9
|
import PopupFooter from './footer';
|
|
8
10
|
import formatOptionLabel from './formatOptionLabel';
|
|
9
11
|
import { asyncPopupSelectMessages } from './messages';
|
|
10
12
|
import PopupTrigger from './trigger';
|
|
11
13
|
// Needed to disable filtering from react-select
|
|
12
14
|
const noFilterOptions = () => true;
|
|
15
|
+
const SEARCH_DEBOUNCE_MS = 350;
|
|
13
16
|
const AsyncPopupSelect = ({
|
|
14
17
|
filterType,
|
|
15
18
|
cloudId,
|
|
16
19
|
selection,
|
|
17
|
-
onSelectionChange = () => {}
|
|
20
|
+
onSelectionChange = () => {},
|
|
21
|
+
isDisabled = false
|
|
18
22
|
}) => {
|
|
19
23
|
const {
|
|
20
24
|
formatMessage
|
|
@@ -31,11 +35,15 @@ const AsyncPopupSelect = ({
|
|
|
31
35
|
filterType,
|
|
32
36
|
cloudId
|
|
33
37
|
});
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
const handleDebouncedFetchFilterOptions = useMemo(() => debounce(fetchFilterOptions, SEARCH_DEBOUNCE_MS), [fetchFilterOptions]);
|
|
39
|
+
const handleInputChange = useCallback(async (newSearchTerm, actionMeta) => {
|
|
40
|
+
if (actionMeta.action === 'input-change' && newSearchTerm !== searchTerm) {
|
|
41
|
+
setSearchTerm(newSearchTerm);
|
|
42
|
+
handleDebouncedFetchFilterOptions({
|
|
43
|
+
searchString: newSearchTerm
|
|
44
|
+
});
|
|
37
45
|
}
|
|
38
|
-
}, [searchTerm]);
|
|
46
|
+
}, [handleDebouncedFetchFilterOptions, searchTerm]);
|
|
39
47
|
const handleOptionSelection = newValue => {
|
|
40
48
|
setSelectedOptions(newValue);
|
|
41
49
|
onSelectionChange(newValue);
|
|
@@ -53,6 +61,9 @@ const AsyncPopupSelect = ({
|
|
|
53
61
|
}
|
|
54
62
|
}, [status]);
|
|
55
63
|
const isLoading = status === 'loading' || status === 'empty';
|
|
64
|
+
const shouldShowFooter = status === 'resolved' && filterOptions.length > 0;
|
|
65
|
+
const options = isLoading ? [] : filterOptions; // if not set to [], then on loading, no loading UI will be shown
|
|
66
|
+
|
|
56
67
|
return /*#__PURE__*/React.createElement(PopupSelect, {
|
|
57
68
|
isMulti: true,
|
|
58
69
|
maxMenuWidth: 300,
|
|
@@ -74,9 +85,12 @@ const AsyncPopupSelect = ({
|
|
|
74
85
|
components: {
|
|
75
86
|
/* @ts-expect-error - This component has stricter OptionType, hence a temp setup untill its made generic */
|
|
76
87
|
Option: CheckboxOption,
|
|
77
|
-
Control: CustomControl
|
|
88
|
+
Control: CustomControl,
|
|
89
|
+
LoadingIndicator: undefined,
|
|
90
|
+
// disables the three ... indicator in the searchbox when picker is loading
|
|
91
|
+
DropdownIndicator: CustomDropdownIndicator
|
|
78
92
|
},
|
|
79
|
-
options:
|
|
93
|
+
options: options,
|
|
80
94
|
value: selectedOptions,
|
|
81
95
|
filterOption: noFilterOptions,
|
|
82
96
|
formatOptionLabel: formatOptionLabel,
|
|
@@ -88,9 +102,10 @@ const AsyncPopupSelect = ({
|
|
|
88
102
|
}) => /*#__PURE__*/React.createElement(PopupTrigger, _extends({}, triggerProps, {
|
|
89
103
|
filterType: filterType,
|
|
90
104
|
isSelected: isOpen,
|
|
91
|
-
onClick: handleOpenPopup
|
|
105
|
+
onClick: handleOpenPopup,
|
|
106
|
+
isDisabled: isDisabled
|
|
92
107
|
})),
|
|
93
|
-
footer: /*#__PURE__*/React.createElement(PopupFooter, {
|
|
108
|
+
footer: shouldShowFooter && /*#__PURE__*/React.createElement(PopupFooter, {
|
|
94
109
|
currentDisplayCount: filterOptions.length,
|
|
95
110
|
totalCount: totalCount
|
|
96
111
|
})
|
|
@@ -6,12 +6,14 @@ import { asyncPopupSelectMessages } from './messages';
|
|
|
6
6
|
const PopupTrigger = /*#__PURE__*/forwardRef(({
|
|
7
7
|
filterType,
|
|
8
8
|
isSelected,
|
|
9
|
+
isDisabled,
|
|
9
10
|
onClick
|
|
10
11
|
}, ref) => {
|
|
11
12
|
return /*#__PURE__*/React.createElement(Button, {
|
|
12
13
|
ref: ref,
|
|
13
14
|
appearance: "default",
|
|
14
15
|
isSelected: isSelected,
|
|
16
|
+
isDisabled: isDisabled,
|
|
15
17
|
onClick: onClick,
|
|
16
18
|
testId: `jlol-basic-filter-${filterType}-trigger`,
|
|
17
19
|
iconAfter: /*#__PURE__*/React.createElement(ChevronDownIcon, {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { CLAUSE_TYPE_COMPOUND, CLAUSE_TYPE_TERMINAL, OPERAND_TYPE_VALUE } from '@atlaskit/jql-ast';
|
|
2
|
+
import { fuzzyCharacter } from '../../jira-search-container/buildJQL';
|
|
3
|
+
const removeFuzzyCharacter = value => {
|
|
4
|
+
if (value !== null && value !== void 0 && value.endsWith(fuzzyCharacter)) {
|
|
5
|
+
return value.slice(0, -1);
|
|
6
|
+
}
|
|
7
|
+
return value;
|
|
8
|
+
};
|
|
9
|
+
const getValueFromTerminalClause = clause => {
|
|
10
|
+
const {
|
|
11
|
+
operand
|
|
12
|
+
} = clause;
|
|
13
|
+
return operand !== undefined && operand.operandType === OPERAND_TYPE_VALUE && removeFuzzyCharacter(operand.value) || undefined;
|
|
14
|
+
};
|
|
15
|
+
const areClauseFieldValuesEqual = (clauseA, clauseB, clauseC) => {
|
|
16
|
+
const valueA = clauseA && getValueFromTerminalClause(clauseA);
|
|
17
|
+
const valueB = clauseB && getValueFromTerminalClause(clauseB);
|
|
18
|
+
const valueC = clauseC && getValueFromTerminalClause(clauseC);
|
|
19
|
+
const values = [valueA, valueB, valueC].filter(Boolean);
|
|
20
|
+
|
|
21
|
+
// checks if valid fields, text, summary and key have the same value, if not, its a complex query and cannnot be recreated in basic mode
|
|
22
|
+
return values.length > 1 && values.every(value => value === values[0]);
|
|
23
|
+
};
|
|
24
|
+
const areClauseFieldKeysAllowed = (clauseA, clauseB, clauseC) => {
|
|
25
|
+
const fieldA = clauseA.field.value;
|
|
26
|
+
const fieldB = clauseB.field.value;
|
|
27
|
+
const fieldC = clauseC === null || clauseC === void 0 ? void 0 : clauseC.field.value; // clauseC only if jql with 3 OR clauses, 'text ~ "EDM-6023*" or summary ~ "EDM-6023*" or key = EDM-6023 ORDER BY created DESC',
|
|
28
|
+
|
|
29
|
+
return [fieldA, fieldB, fieldC].filter(Boolean).every(field => ['summary', 'text', 'key'].includes(field));
|
|
30
|
+
};
|
|
31
|
+
const doesCompoundClauseContainAllTerminalClauses = clauses => {
|
|
32
|
+
return clauses.every(clauses => clauses.clauseType === CLAUSE_TYPE_TERMINAL);
|
|
33
|
+
};
|
|
34
|
+
export const isClauseTooComplex = (clauses, key) => {
|
|
35
|
+
if (key === 'text') {
|
|
36
|
+
const [clause] = clauses;
|
|
37
|
+
if (clause.clauseType === CLAUSE_TYPE_COMPOUND) {
|
|
38
|
+
const textClauses = clause.clauses;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* valid: text ~ "test*" or summary ~ "test*" ORDER BY created DESC
|
|
42
|
+
* valid: text ~ "EDM-6023*" or summary ~ "EDM-6023*" or key = EDM-6023 ORDER BY created DESC
|
|
43
|
+
* invalid: assignee = "me" or text ~ "EDM-6023*" or summary ~ "EDM-6023*" or key = EDM-6023 ORDER BY created DESC
|
|
44
|
+
*/
|
|
45
|
+
if (textClauses.length !== 2 && textClauses.length !== 3) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* valid: text ~ "test*" or summary ~ "test*"
|
|
51
|
+
* invalid: text ~ "test" or (summary ~ "test" or key = "test")
|
|
52
|
+
*/
|
|
53
|
+
if (!doesCompoundClauseContainAllTerminalClauses(textClauses)) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
const [clauseA, clauseB, clauseC] = textClauses;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* valid: text ~ "EDM-6023*" or summary ~ "EDM-6023*" or key = EDM-6023 ORDER BY created DESC
|
|
60
|
+
* invalid: text ~ "EDM-6023*" or summary ~ "anotherValue" ORDER BY created DESC
|
|
61
|
+
* invalid: text ~ "EDM-6023*" or text ~ "anotherValue" ORDER BY created DESC
|
|
62
|
+
*/
|
|
63
|
+
if (!areClauseFieldValuesEqual(clauseA, clauseB, clauseC)) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* valid: text ~ "EDM-6023*" ORDER BY created DESC
|
|
69
|
+
* invalid: resolution = 40134 ORDER BY created DESC
|
|
70
|
+
*/
|
|
71
|
+
if (!areClauseFieldKeysAllowed(clauseA, clauseB, clauseC)) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return clauses.length > 1;
|
|
77
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import _defineProperty from "@babel/runtime/helpers/defineProperty";
|
|
2
|
+
import mergeWith from 'lodash/mergeWith';
|
|
3
|
+
import { AbstractJastVisitor, COMPOUND_OPERATOR_AND, COMPOUND_OPERATOR_OR, JastBuilder, OPERATOR_EQUALS, OPERATOR_GT_EQUALS, OPERATOR_IN, OPERATOR_LIKE } from '@atlaskit/jql-ast';
|
|
4
|
+
import { getBooleanFF } from '@atlaskit/platform-feature-flags';
|
|
5
|
+
import { ALLOWED_ORDER_BY_KEYS } from '../../jira-search-container';
|
|
6
|
+
import { isClauseTooComplex } from './isClauseTooComplex';
|
|
7
|
+
import { isValidJql } from './index';
|
|
8
|
+
|
|
9
|
+
// Map of field keys to their respective clauses in the Jast
|
|
10
|
+
|
|
11
|
+
const allowedFields = [
|
|
12
|
+
// basic filter fields
|
|
13
|
+
'assignee', 'issuetype', 'project', 'status',
|
|
14
|
+
// search input fields
|
|
15
|
+
'text', 'summary', 'key',
|
|
16
|
+
// orderby field
|
|
17
|
+
'created'];
|
|
18
|
+
const fallbackOperators = [OPERATOR_IN];
|
|
19
|
+
const fieldSpecificOperators = {
|
|
20
|
+
text: [OPERATOR_LIKE, OPERATOR_EQUALS],
|
|
21
|
+
summary: [OPERATOR_LIKE, OPERATOR_EQUALS],
|
|
22
|
+
key: [OPERATOR_EQUALS],
|
|
23
|
+
created: [OPERATOR_GT_EQUALS],
|
|
24
|
+
project: [OPERATOR_IN, OPERATOR_EQUALS],
|
|
25
|
+
issuetype: [OPERATOR_IN, OPERATOR_EQUALS],
|
|
26
|
+
status: [OPERATOR_IN, OPERATOR_EQUALS],
|
|
27
|
+
assignee: [OPERATOR_IN, OPERATOR_EQUALS]
|
|
28
|
+
};
|
|
29
|
+
class JqlClauseCollectingVisitorError extends Error {}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Rather than having to navigate the entire tree structure ourself, we extend AbstractJastVisitor
|
|
33
|
+
* class and implement visitor functions for node types that we wish to process.
|
|
34
|
+
* A list of available visitor can be viewed in packages/jql/jql-ast/src/types/api/jast-visitor.ts
|
|
35
|
+
* more info - https://atlaskit.atlassian.com/packages/jql/jql-ast/docs/traversing-the-ast
|
|
36
|
+
* */
|
|
37
|
+
class JqlClauseCollectingVisitor extends AbstractJastVisitor {
|
|
38
|
+
constructor() {
|
|
39
|
+
super();
|
|
40
|
+
_defineProperty(this, "visitNotClause", () => {
|
|
41
|
+
throw new JqlClauseCollectingVisitorError('Visited an unsupported node while traversing the AST');
|
|
42
|
+
});
|
|
43
|
+
_defineProperty(this, "visitOrderByField", orderByField => {
|
|
44
|
+
var _orderByField$field$v;
|
|
45
|
+
const fieldValue = (_orderByField$field$v = orderByField.field.value) === null || _orderByField$field$v === void 0 ? void 0 : _orderByField$field$v.toLowerCase();
|
|
46
|
+
if (fieldValue && !ALLOWED_ORDER_BY_KEYS.includes(fieldValue)) {
|
|
47
|
+
throw new JqlClauseCollectingVisitorError(`query with order by field '${fieldValue}' is not supported`);
|
|
48
|
+
}
|
|
49
|
+
return {};
|
|
50
|
+
});
|
|
51
|
+
_defineProperty(this, "visitCompoundClause", compoundClause => {
|
|
52
|
+
const clauseMap = {};
|
|
53
|
+
const operator = compoundClause.operator.value;
|
|
54
|
+
if (operator === COMPOUND_OPERATOR_AND) {
|
|
55
|
+
return compoundClause.clauses.reduce((result, clause) => this.aggregateResult(clause.accept(this), result), clauseMap);
|
|
56
|
+
}
|
|
57
|
+
if (operator === COMPOUND_OPERATOR_OR) {
|
|
58
|
+
// this is delt with in isClauseTooComplex
|
|
59
|
+
return this.aggregateResult({
|
|
60
|
+
text: [compoundClause]
|
|
61
|
+
}, clauseMap);
|
|
62
|
+
}
|
|
63
|
+
throw new JqlClauseCollectingVisitorError(`Compound clauses using the operator '${operator}' is not supported`);
|
|
64
|
+
});
|
|
65
|
+
_defineProperty(this, "visitTerminalClause", terminalClause => {
|
|
66
|
+
var _terminalClause$opera;
|
|
67
|
+
const fieldName = terminalClause.field.value.toLowerCase();
|
|
68
|
+
if (!allowedFields.includes(fieldName)) {
|
|
69
|
+
throw new JqlClauseCollectingVisitorError(`Field with name '${fieldName}' of type ${terminalClause.clauseType} is not supported`);
|
|
70
|
+
}
|
|
71
|
+
const operator = (_terminalClause$opera = terminalClause.operator) === null || _terminalClause$opera === void 0 ? void 0 : _terminalClause$opera.value;
|
|
72
|
+
const allowedOperators = fieldSpecificOperators[fieldName] || fallbackOperators;
|
|
73
|
+
if (operator && !allowedOperators.includes(operator.toLowerCase())) {
|
|
74
|
+
throw new JqlClauseCollectingVisitorError(`Field with name '${fieldName}' using operator ${operator} is not supported`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
[terminalClause.field.value.toLowerCase()]: [terminalClause]
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
aggregateResult(aggregate, nextResult) {
|
|
82
|
+
return mergeWith(aggregate, nextResult, (destValue, srcValue) => srcValue.concat(destValue !== null && destValue !== void 0 ? destValue : []));
|
|
83
|
+
}
|
|
84
|
+
defaultResult() {
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export const isQueryTooComplex = jql => {
|
|
89
|
+
if (!getBooleanFF('platform.linking-platform.datasource.show-jlol-basic-filters') || !jql) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
if (!isValidJql(jql)) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
const jast = new JastBuilder().build(jql);
|
|
96
|
+
try {
|
|
97
|
+
const jqlClauseCollectingVisitor = new JqlClauseCollectingVisitor();
|
|
98
|
+
const clauseMap = jast.query ? jast.query.accept(jqlClauseCollectingVisitor) : {}; // jast.query is defined as void | Query, hence the fallback
|
|
99
|
+
|
|
100
|
+
const hasAnyKeyWithComplexClause = Object.entries(clauseMap).some(([key, clauses]) => isClauseTooComplex(clauses, key));
|
|
101
|
+
return hasAnyKeyWithComplexClause;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { COMPOUND_OPERATOR_AND, COMPOUND_OPERATOR_OR, creators, JastBuilder, OPERATOR_EQUALS, OPERATOR_GT_EQUALS, OPERATOR_IN, OPERATOR_LIKE, ORDER_BY_DIRECTION_ASC, ORDER_BY_DIRECTION_DESC, print } from '@atlaskit/jql-ast';
|
|
2
2
|
const fuzzySearchRegExp = /^"(.+)"$/;
|
|
3
3
|
const jiraIssueKeyRegExp = /[A-Z]+-\d+/;
|
|
4
|
+
export const fuzzyCharacter = '*';
|
|
4
5
|
const constructTerminalClause = (field, operator, value) => creators.terminalClause(creators.field(field), creators.operator(operator), creators.valueOperand(value));
|
|
5
6
|
export const buildJQL = input => {
|
|
6
7
|
/**
|
|
@@ -22,7 +23,7 @@ export const buildJQL = input => {
|
|
|
22
23
|
return '';
|
|
23
24
|
}
|
|
24
25
|
if (trimmedRawSearch) {
|
|
25
|
-
const fuzzy = !trimmedRawSearch.match(fuzzySearchRegExp) ?
|
|
26
|
+
const fuzzy = !trimmedRawSearch.match(fuzzySearchRegExp) ? fuzzyCharacter : '';
|
|
26
27
|
const basicSearch = trimmedRawSearch.replace(/['"?*]+/g, '');
|
|
27
28
|
const text = constructTerminalClause('text', OPERATOR_LIKE, `${basicSearch}${fuzzy}`);
|
|
28
29
|
const summary = constructTerminalClause('summary', OPERATOR_LIKE, `${basicSearch}${fuzzy}`);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/** @jsx jsx */
|
|
2
|
-
import React, { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
3
3
|
import { css, jsx } from '@emotion/react';
|
|
4
4
|
import { useIntl } from 'react-intl-next';
|
|
5
5
|
import { getBooleanFF } from '@atlaskit/platform-feature-flags';
|
|
6
6
|
import { useDatasourceAnalyticsEvents } from '../../../analytics';
|
|
7
7
|
import { BasicFilters } from '../basic-filters';
|
|
8
|
+
import { isQueryTooComplex } from '../basic-filters/utils/isQueryTooComplex';
|
|
8
9
|
import { BasicSearchInput } from '../basic-search-input';
|
|
9
10
|
import { JiraJQLEditor } from '../jql-editor';
|
|
10
11
|
import { ModeSwitcher } from '../mode-switcher';
|
|
@@ -17,6 +18,7 @@ const inputContainerStyles = css({
|
|
|
17
18
|
minHeight: '60px'
|
|
18
19
|
});
|
|
19
20
|
const DEFAULT_JQL_QUERY = 'created >= -30d order by created DESC';
|
|
21
|
+
export const ALLOWED_ORDER_BY_KEYS = ['key', 'summary', 'assignee', 'status', 'created'];
|
|
20
22
|
const JiraSearchMethodSwitcher = ModeSwitcher;
|
|
21
23
|
export const JiraSearchContainer = props => {
|
|
22
24
|
const {
|
|
@@ -36,6 +38,7 @@ export const JiraSearchContainer = props => {
|
|
|
36
38
|
const [basicSearchTerm, setBasicSearchTerm] = useState('');
|
|
37
39
|
const [currentSearchMethod, setCurrentSearchMethod] = useState(initialSearchMethod);
|
|
38
40
|
const [jql, setJql] = useState(initialJql || DEFAULT_JQL_QUERY);
|
|
41
|
+
const [isComplexQuery, setIsComplexQuery] = useState(false);
|
|
39
42
|
const [orderKey, setOrderKey] = useState();
|
|
40
43
|
const [orderDirection, setOrderDirection] = useState();
|
|
41
44
|
const {
|
|
@@ -63,7 +66,7 @@ export const JiraSearchContainer = props => {
|
|
|
63
66
|
const order = hasOrder ? (_fragments$at3 = fragments.at(-1)) === null || _fragments$at3 === void 0 ? void 0 : _fragments$at3.split(' ').at(-1) : undefined;
|
|
64
67
|
|
|
65
68
|
// TODO: confirm if these are the only order keys we want to preserve - existing whiteboard logic
|
|
66
|
-
if (key &&
|
|
69
|
+
if (key && ALLOWED_ORDER_BY_KEYS.includes(key)) {
|
|
67
70
|
setOrderKey(key);
|
|
68
71
|
setOrderDirection(order);
|
|
69
72
|
}
|
|
@@ -73,6 +76,7 @@ export const JiraSearchContainer = props => {
|
|
|
73
76
|
onSearch({
|
|
74
77
|
jql
|
|
75
78
|
}, currentSearchMethod);
|
|
79
|
+
setIsComplexQuery(isQueryTooComplex(jql));
|
|
76
80
|
if (currentSearchMethod === 'basic') {
|
|
77
81
|
fireEvent('ui.form.submitted.basicSearch', {});
|
|
78
82
|
} else if (currentSearchMethod === 'jql') {
|
|
@@ -85,6 +89,10 @@ export const JiraSearchContainer = props => {
|
|
|
85
89
|
}
|
|
86
90
|
return false;
|
|
87
91
|
}, []);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
setIsComplexQuery(isQueryTooComplex(jql));
|
|
94
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
95
|
+
}, []);
|
|
88
96
|
return jsx("div", {
|
|
89
97
|
css: inputContainerStyles
|
|
90
98
|
}, currentSearchMethod === 'basic' && jsx(React.Fragment, null, jsx(BasicSearchInput, {
|
|
@@ -109,7 +117,9 @@ export const JiraSearchContainer = props => {
|
|
|
109
117
|
value: 'jql'
|
|
110
118
|
}, {
|
|
111
119
|
label: formatMessage(modeSwitcherMessages.basicTextSearchLabel),
|
|
112
|
-
value: 'basic'
|
|
120
|
+
value: 'basic',
|
|
121
|
+
disabled: isComplexQuery,
|
|
122
|
+
tooltipText: isComplexQuery ? formatMessage(modeSwitcherMessages.basicModeSwitchDisabledTooltipText) : ''
|
|
113
123
|
}]
|
|
114
124
|
}));
|
|
115
125
|
};
|
|
@@ -4,5 +4,10 @@ export const modeSwitcherMessages = defineMessages({
|
|
|
4
4
|
id: 'linkDataSource.jira-issues.configmodal.basicModeText',
|
|
5
5
|
description: 'Display text for basic text search toggle button',
|
|
6
6
|
defaultMessage: 'Basic'
|
|
7
|
+
},
|
|
8
|
+
basicModeSwitchDisabledTooltipText: {
|
|
9
|
+
id: 'linkDataSource.jira-issues.configmodal.basicModeSwitchDisabledTooltipText',
|
|
10
|
+
description: 'Display tooltip text when basic mode switch is disabled',
|
|
11
|
+
defaultMessage: "You can't switch to basic for this query."
|
|
7
12
|
}
|
|
8
13
|
});
|