Skip to content

Commit 705d0c2

Browse files
committed
Added: Tags
Chore: Lint code Added: Tests for tags Fixed: Sidebar tests
1 parent b43ecd4 commit 705d0c2

File tree

14 files changed

+349
-32
lines changed

14 files changed

+349
-32
lines changed

src/Tree/TreeCache.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,4 @@ class TreeCache {
5050
}
5151
}
5252

53-
export default TreeCache;
53+
export default TreeCache;

src/Tree/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Tree from './Tree';
22
import ComputedTree from './ComputedTree';
33

4-
export { Tree, ComputedTree };
4+
export {Tree, ComputedTree};
55

6-
export default Tree;
6+
export default Tree;
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {fireEvent, within} from '@testing-library/dom';
2+
import userEvent from '@testing-library/user-event';
3+
import React from 'react';
4+
import {Classes} from '@blueprintjs/select';
5+
import {act, render, StoreCreator, screen} from '../../../../../test-utils';
6+
import Content from '../../index';
7+
8+
//const path = creator.createPath('/user/abc');
9+
//stores.uiStore.setActiveNode(path);
10+
//const {container} = render(<Content />, {providerProps: {value: stores}});
11+
12+
describe('Tags tests', () => {
13+
const getTagBtn = () => screen.getByRole('button', {name: /tags/});
14+
15+
const getPopover = () => {
16+
return document.querySelector(`.tagpopover`);
17+
};
18+
19+
const getTagsInsideSelect = () => {
20+
return getPopover().querySelectorAll('.bp3-tag');
21+
};
22+
23+
const getNoResultDropdown = () => {
24+
return document.querySelector('.tagsuggest-noresult');
25+
};
26+
27+
const getSuggestionsPopup = () => {
28+
return document.querySelector(`.${Classes.MULTISELECT_POPOVER}`);
29+
};
30+
31+
it('can add global tags', async () => {
32+
const {stores} = StoreCreator();
33+
render(<Content />, {providerProps: {value: stores}});
34+
expect(getTagBtn()).toBeInTheDocument();
35+
36+
userEvent.click(getTagBtn());
37+
expect(getTagsInsideSelect()).toHaveLength(0);
38+
39+
// Add a tag
40+
await userEvent.type(within(getPopover()).getByRole(/textbox/), 'cxtag', {
41+
delay: 1,
42+
});
43+
userEvent.keyboard('{Enter}');
44+
45+
expect(getTagsInsideSelect()).toHaveLength(1);
46+
expect(getTagsInsideSelect()[0]).toHaveTextContent(/cxtag/);
47+
expect(getNoResultDropdown()).not.toBeInTheDocument();
48+
49+
// Tags are also added to suggestions
50+
expect(within(getSuggestionsPopup()).getAllByRole(/listitem/)).toHaveLength(
51+
1,
52+
);
53+
expect(
54+
within(getSuggestionsPopup()).getAllByRole(/listitem/)[0],
55+
).toHaveTextContent(/cxtag/);
56+
});
57+
58+
it('has no suggestions when no global tags are present', async () => {
59+
const {stores} = StoreCreator();
60+
61+
render(<Content />, {providerProps: {value: stores}});
62+
expect(getTagBtn()).toBeInTheDocument();
63+
64+
userEvent.click(getTagBtn());
65+
expect(getTagsInsideSelect()).toHaveLength(0);
66+
67+
// Check list dropdown
68+
await act(async () => {
69+
await userEvent.click(within(getPopover()).getByRole(/textbox/));
70+
});
71+
72+
expect(getTagsInsideSelect()).toHaveLength(0);
73+
expect(getNoResultDropdown()).toBeInTheDocument();
74+
expect(getNoResultDropdown()).toHaveTextContent(/No results./);
75+
});
76+
77+
it('shows global tags suggestions in path node', async () => {
78+
const {stores, creator} = StoreCreator();
79+
80+
const {rerender} = render(<Content />, {providerProps: {value: stores}});
81+
// Add a tag
82+
userEvent.click(getTagBtn());
83+
await userEvent.type(within(getPopover()).getByRole(/textbox/), 'cxtag', {
84+
delay: 1,
85+
});
86+
userEvent.keyboard('{Enter}');
87+
88+
const path = creator.createPath('/user/abc');
89+
stores.uiStore.setActiveNode(path);
90+
91+
rerender(<Content />, {providerProps: {value: stores}});
92+
userEvent.click(getTagBtn());
93+
expect(getTagsInsideSelect()).toHaveLength(0);
94+
expect(within(getSuggestionsPopup()).getAllByRole(/listitem/)).toHaveLength(
95+
1,
96+
);
97+
expect(
98+
within(getSuggestionsPopup()).getAllByRole(/listitem/)[0],
99+
).toHaveTextContent(/cxtag/);
100+
});
101+
});

src/components/Content/index.js

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,9 @@ const getComponentForNode = (node) => {
3939
}
4040
};
4141

42-
const SubContent = observer(({node}) => {
42+
const SubContent = observer(({node, relativeJsonPath}) => {
4343
const stores = React.useContext(StoresContext);
4444
const {activeWidget, widgets} = stores.uiStore;
45-
let relativeJsonPath = [];
46-
if (node.category === NodeCategories.SourceMap) {
47-
relativeJsonPath = node.relativeJsonPath;
48-
}
4945
const RenderSubContent = getComponentForNode(node);
5046
return activeWidget ? (
5147
<div className="flex-1 relative flex">
@@ -67,11 +63,16 @@ const Content = observer(() => {
6763
const stores = React.useContext(StoresContext);
6864
const {activeNode, activeView, views} = stores.uiStore;
6965
const sourceNode = stores.graphStore.rootNode;
66+
const node = activeNode || sourceNode;
67+
const relativeJsonPath =
68+
node.category === NodeCategories.SourceMap ? node.relativeJsonPath : [];
7069

7170
return (
7271
<StyledContent className={'flex flex-col flex-1'}>
7372
<div className="bp3-dark relative flex flex-1 flex-col bg-canvas">
7473
<Options
74+
relativeJsonPath={relativeJsonPath}
75+
node={node}
7576
view={activeView}
7677
onToggleView={(v) => stores.uiStore.setActiveView(v)}
7778
onToggleWidget={(w) => stores.uiStore.setActiveWidget(w)}
@@ -80,7 +81,7 @@ const Content = observer(() => {
8081
}}
8182
/>
8283
{activeView === views.form && (
83-
<SubContent node={activeNode || sourceNode} />
84+
<SubContent node={node} relativeJsonPath={relativeJsonPath} />
8485
)}
8586
{activeView === views.code && <MonacoEditor />}
8687
{activeView === views.preview && (

src/components/Content/options.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import classnames from 'classnames';
55
import {Button, ButtonGroup, Icon, Intent} from '@blueprintjs/core';
66
import {StoresContext} from '../Context';
77
import {NodeCategories} from '../../datasets/tree';
8+
import Tags from './tags';
89

9-
const Options = observer((props) => {
10+
const Options = observer(({relativeJsonPath, node, ...props}) => {
1011
const stores = React.useContext(StoresContext);
11-
const {activeNode, activeWidget, widgets} = stores.uiStore;
12+
const {activeWidget, widgets} = stores.uiStore;
1213
const [confirmDelete, setConfirmDelete] = React.useState(false);
1314
const {errors, hints, info, warning} = stores.lintStore;
1415

@@ -28,8 +29,9 @@ const Options = observer((props) => {
2829
'border-transparent': !activeWidget,
2930
},
3031
)}>
31-
{activeNode && activeNode.category === NodeCategories.SourceMap && (
32-
<div className="flex items-center">
32+
<div className="flex items-center">
33+
<Tags relativeJsonPath={relativeJsonPath} node={node} />
34+
{node && node.category === NodeCategories.SourceMap && (
3335
<Button
3436
small
3537
icon={<Icon size={14} icon="trash" />}
@@ -47,8 +49,8 @@ const Options = observer((props) => {
4749
}
4850
}}
4951
/>
50-
</div>
51-
)}
52+
)}
53+
</div>
5254
<div className="flex-1" />
5355
<div>
5456
<ButtonGroup>

src/components/Content/tags.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import {observer} from 'mobx-react-lite';
4+
import {uniq} from 'lodash';
5+
import {getValueFromStore, usePatchOperation} from '../../utils/selectors';
6+
import {Button, Icon} from '@blueprintjs/core';
7+
import {TagSuggest} from '../Pickers';
8+
import {NodeCategories, nodeOperations} from '../../datasets/tree';
9+
import {Popover2} from '@blueprintjs/popover2';
10+
11+
const Tags = observer(({relativeJsonPath, node}) => {
12+
const handlePatch = usePatchOperation();
13+
const getTags = React.useCallback(() => {
14+
let tags =
15+
getValueFromStore(relativeJsonPath.concat(['tags']), false) || [];
16+
if (relativeJsonPath.length === 0) {
17+
tags = tags.map((i) => i.name);
18+
}
19+
return tags;
20+
}, [relativeJsonPath]);
21+
22+
const getSuggestions = React.useCallback(
23+
(tags) => {
24+
let globalTags = getValueFromStore(['tags'], false) || [];
25+
globalTags = globalTags.map((i) => i.name);
26+
return uniq([...tags, ...globalTags]);
27+
},
28+
[relativeJsonPath],
29+
);
30+
31+
const tags = getTags();
32+
const suggestedTags = getSuggestions(tags);
33+
34+
const displayTags = (tags) => {
35+
if (tags && tags.length > 1) {
36+
return `${tags[0]} + ${tags.length - 1}`;
37+
}
38+
return tags.length ? tags[0] : tags;
39+
};
40+
41+
return (
42+
<Popover2
43+
content={
44+
<TagSuggest
45+
className="tag-suggest"
46+
popoverClassname="tagpopover"
47+
items={suggestedTags}
48+
selectedItems={tags}
49+
onItemSelect={(item) => {
50+
let toSave = [...tags, item];
51+
if (node.category !== NodeCategories.SourceMap) {
52+
toSave = toSave.map((i) => ({name: i}));
53+
}
54+
handlePatch(
55+
nodeOperations.Add,
56+
relativeJsonPath.concat(['tags']),
57+
toSave,
58+
);
59+
}}
60+
onItemRemove={(item) => {
61+
let remaining = tags.filter((t) => t !== item);
62+
if (node.category !== NodeCategories.SourceMap) {
63+
remaining = remaining.map((t) => ({name: t}));
64+
}
65+
handlePatch(
66+
nodeOperations.Replace,
67+
relativeJsonPath.concat(['tags']),
68+
remaining,
69+
);
70+
}}
71+
/>
72+
}
73+
placement="bottom">
74+
<Button
75+
small
76+
aria-label="tags"
77+
className="max-w-xs mr-3 truncate"
78+
icon={<Icon size={14} icon="tag" />}
79+
text={displayTags(tags)}
80+
/>
81+
</Popover2>
82+
);
83+
});
84+
85+
Tags.propTypes = {
86+
relativeJsonPath: PropTypes.array,
87+
node: PropTypes.object,
88+
};
89+
90+
export default Tags;
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//@flow
2+
import React from 'react';
3+
import PropTypes from 'prop-types';
4+
import {Icon, MenuItem} from '@blueprintjs/core';
5+
import {MultiSelect} from '@blueprintjs/select';
6+
import {highlightText} from '../../utils';
7+
8+
const TagSuggest = ({
9+
items,
10+
selectedItems,
11+
onItemSelect,
12+
onItemRemove,
13+
className,
14+
...props
15+
}) => {
16+
const filterTags = (query, tag, _index, exactMatch) => {
17+
const normalizedTitle = tag.toLowerCase();
18+
const normalizedQuery = query.toLowerCase();
19+
20+
if (exactMatch) {
21+
return normalizedTitle === normalizedQuery;
22+
} else {
23+
return tag.indexOf(normalizedQuery) >= 0;
24+
}
25+
};
26+
27+
const itemRenderer = (text, {modifiers, handleClick, query}) =>
28+
modifiers.matchesPredicate ? (
29+
<MenuItem
30+
active={modifiers.active}
31+
key={text}
32+
data-testid={text}
33+
onClick={handleClick}
34+
shouldDismissPopover={false}
35+
icon={
36+
selectedItems.indexOf(text) >= 0 ? (
37+
<Icon size={12} icon="small-tick" />
38+
) : undefined
39+
}
40+
text={highlightText(text, query)}
41+
/>
42+
) : null;
43+
44+
const newItemRenderer = React.useCallback(
45+
(item, isActive, onClick) => (
46+
<MenuItem
47+
icon="add"
48+
text={`Create "${item}"`}
49+
active={isActive}
50+
onClick={onClick}
51+
shouldDismissPopover={false}
52+
/>
53+
),
54+
[],
55+
);
56+
57+
return (
58+
<MultiSelect
59+
className={className}
60+
selectedItems={selectedItems}
61+
items={items}
62+
noResults={
63+
<MenuItem
64+
className="tagsuggest-noresult"
65+
disabled={true}
66+
text="No results."
67+
/>
68+
}
69+
tagRenderer={(item) => item}
70+
itemRenderer={itemRenderer}
71+
createNewItemFromQuery={(e) => e}
72+
createNewItemRenderer={newItemRenderer}
73+
onItemSelect={(item) => onItemSelect(item)}
74+
tagInputProps={{
75+
onRemove: (item) => onItemRemove(item),
76+
}}
77+
inputProps={{
78+
placeholder: 'Create or choose existing',
79+
}}
80+
popoverProps={{
81+
minimal: true,
82+
targetClassName: `w-full ${props.popoverClassname || ''}`,
83+
shouldReturnFocusOnClose: false,
84+
}}
85+
itemPredicate={filterTags}
86+
resetOnClose={false}
87+
resetOnQuery={false}
88+
resetOnSelect={true}
89+
/>
90+
);
91+
};
92+
93+
TagSuggest.propTypes = {
94+
inputRef: PropTypes.any,
95+
selectedItems: PropTypes.array,
96+
className: PropTypes.string,
97+
popoverClassname: PropTypes.string,
98+
items: PropTypes.array,
99+
selectedItem: PropTypes.any,
100+
onItemSelect: PropTypes.func,
101+
onItemRemove: PropTypes.func,
102+
};
103+
104+
export default TagSuggest;

0 commit comments

Comments
 (0)