@truedat/bg 8.4.7 → 8.4.8

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/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@truedat/bg",
3
- "version": "8.4.7",
3
+ "version": "8.4.8",
4
4
  "description": "Truedat Web Business Glossary",
5
- "sideEffects": false,
5
+ "sideEffects": [
6
+ "**/*.css",
7
+ "**/*.less"
8
+ ],
6
9
  "module": "src/index.js",
7
10
  "files": [
8
11
  "src",
@@ -51,7 +54,7 @@
51
54
  "@testing-library/jest-dom": "^6.6.3",
52
55
  "@testing-library/react": "^16.3.0",
53
56
  "@testing-library/user-event": "^14.6.1",
54
- "@truedat/test": "8.4.7",
57
+ "@truedat/test": "8.4.8",
55
58
  "identity-obj-proxy": "^3.0.0",
56
59
  "jest": "^29.7.0",
57
60
  "redux-saga-test-plan": "^4.0.6"
@@ -84,5 +87,5 @@
84
87
  "semantic-ui-react": "^3.0.0-beta.2",
85
88
  "swr": "^2.3.3"
86
89
  },
87
- "gitHead": "71fcf487f24eb0b3e5a6962547e0e946c59033ce"
90
+ "gitHead": "7829e377f78c3cfc66e449f5dc31a8b03c0f5f00"
88
91
  }
@@ -1,10 +1,15 @@
1
1
  import _ from "lodash/fp";
2
- import { useEffect, useState, useRef } from "react";
2
+ import debounce from "lodash/debounce";
3
+ import {
4
+ useEffect,
5
+ useMemo,
6
+ useState,
7
+ useRef,
8
+ } from "react";
3
9
  import PropTypes from "prop-types";
4
10
  import { connect } from "react-redux";
5
11
  import {
6
12
  Button,
7
- Dropdown,
8
13
  Segment,
9
14
  Grid,
10
15
  Icon,
@@ -23,12 +28,15 @@ import {
23
28
  graphDistinctTags,
24
29
  EMPTY,
25
30
  } from "@truedat/lm/services/relationGraphTraversal";
31
+ import { buildColorMap } from "@truedat/lm/services/edgeColorPalette";
26
32
  import { RelationGraphDepth } from "@truedat/lm/components";
27
33
  import { config } from "@truedat/core/truedatConfig";
28
34
  import { getConceptToConceptRelations } from "../selectors/getConceptRelations";
29
35
 
30
36
  import ConceptRelationActions from "./ConceptRelationActions";
31
37
  import ConceptRelationRow from "./ConceptRelationRow";
38
+ import SliderWithFilter from "./SliderWithFilter";
39
+
32
40
 
33
41
  export const ConceptRelationsHeaders = () => (
34
42
  <Table.Header>
@@ -84,7 +92,7 @@ export const ConceptRelations = ({
84
92
  hasAppliedConfig.current = true;
85
93
  }, [conceptRelations, location]);
86
94
 
87
- const navigateToConcept = ({ resource_id }, emptyEdges) => {
95
+ const navigateToConcept = ({ resource_id }) => {
88
96
  navigate(
89
97
  linkTo.CONCEPT_LINKS_CONCEPTS({
90
98
  business_concept_id: resource_id,
@@ -93,20 +101,31 @@ export const ConceptRelations = ({
93
101
  );
94
102
  };
95
103
 
96
- const onTraversalTagsChange = (event, { value }) => {
97
- setAllowedTags(value);
98
- };
99
-
100
- const [depth, setDepth] = useState(initialDepth);
101
- const [maxDepth, setMaxDepth] = useState(0);
104
+ const [selectedDepth, setSelectedDepth] = useState(initialDepth);
102
105
  const [currentId, setCurrentId] = useState();
103
106
  const [tagOptions, setTagsOptions] = useState([]);
104
- const [limitedRelationGraph, setLimitedRelationGraph] =
105
- useState(relationsGraph);
106
107
  const [traversalTags, setAllowedTags] = useState([]);
108
+ const [localTraversalTags, setLocalTraversalTags] = useState([]);
109
+ const traversalTagsDebounceMs = Number.isFinite(
110
+ Number(config?.RelatedConceptsTagsDebounceMs)
111
+ )
112
+ ? Number(config?.RelatedConceptsTagsDebounceMs)
113
+ : 500;
114
+ const debouncedSetAllowedTags = useMemo(
115
+ () => debounce((value) => setAllowedTags(value), traversalTagsDebounceMs),
116
+ [traversalTagsDebounceMs]
117
+ );
118
+
119
+ useEffect(
120
+ () => () => {
121
+ debouncedSetAllowedTags.cancel();
122
+ },
123
+ [debouncedSetAllowedTags]
124
+ );
107
125
 
108
- const nonDefaultOptions = (ids, depth, maxDepth) => {
109
- return !_.isEmpty(ids) || depth !== maxDepth;
126
+ const onTraversalTagsChange = (_event, { value }) => {
127
+ setLocalTraversalTags(value);
128
+ debouncedSetAllowedTags(value);
110
129
  };
111
130
 
112
131
  useEffect(() => {
@@ -115,37 +134,46 @@ export const ConceptRelations = ({
115
134
  }, [concept]);
116
135
 
117
136
  useEffect(() => {
118
- if (!_.isEmpty(relationsGraph) && currentId) {
119
- const newMaxDepth = findMaxDepth(
120
- relationsGraph,
121
- currentId,
122
- traversalTags
123
- );
137
+ setSelectedDepth(initialDepth);
138
+ }, [currentId, initialDepth]);
124
139
 
125
- setMaxDepth(newMaxDepth);
126
- setDepth(
127
- _.isEmpty(traversalTags)
128
- ? Math.min(initialDepth, newMaxDepth)
129
- : newMaxDepth
130
- );
131
- }
132
- }, [relationsGraph, currentId, traversalTags, initialDepth]);
140
+ const maxDepth = useMemo(() => {
141
+ if (_.isEmpty(relationsGraph) || !currentId) return 0;
133
142
 
134
- useEffect(() => {
135
- /* just a little optimization to avoid generating a graph if no limitations
136
- * (tags, depth) are set
137
- */
138
- if (nonDefaultOptions(traversalTags, depth, maxDepth)) {
139
- const limitedGraph = pruneGraph(
140
- relationsGraph,
141
- currentId,
142
- depth,
143
- traversalTags
144
- );
143
+ return findMaxDepth(relationsGraph, currentId, traversalTags);
144
+ }, [relationsGraph, currentId, traversalTags]);
145
+
146
+ const depth = useMemo(() => {
147
+ if (_.isEmpty(relationsGraph) || !currentId) return 0;
145
148
 
146
- setLimitedRelationGraph(limitedGraph);
147
- } else setLimitedRelationGraph(relationsGraph);
148
- }, [relationsGraph, currentId, depth, traversalTags, maxDepth]);
149
+ return Number.isFinite(selectedDepth)
150
+ ? Math.min(selectedDepth, maxDepth)
151
+ : maxDepth;
152
+ }, [relationsGraph, currentId, selectedDepth, maxDepth]);
153
+
154
+ const limitedRelationGraph = useMemo(() => {
155
+ if (_.isEmpty(relationsGraph) || !currentId) return { nodes: [], edges: [] };
156
+
157
+ const result = pruneGraph(
158
+ relationsGraph,
159
+ currentId,
160
+ depth,
161
+ traversalTags
162
+ );
163
+
164
+ if (!result) return { nodes: [], edges: [] };
165
+
166
+ return {
167
+ ...result,
168
+ nodes: _.values(_.getOr({}, "nodes")(result)),
169
+ edges: _.getOr([], "edges")(result),
170
+ };
171
+ }, [relationsGraph, currentId, depth, traversalTags]);
172
+
173
+ const colorMap = useMemo(
174
+ () => buildColorMap(relationsGraph?.edges),
175
+ [relationsGraph]
176
+ );
149
177
 
150
178
  useEffect(() => {
151
179
  const tagsToOptions = (tags) =>
@@ -156,6 +184,7 @@ export const ConceptRelations = ({
156
184
  defaultMessage: value.type,
157
185
  }),
158
186
  value: id,
187
+ type: value.type,
159
188
  }),
160
189
  [
161
190
  {
@@ -170,33 +199,18 @@ export const ConceptRelations = ({
170
199
  _.flow(graphDistinctTags, tagsToOptions, setTagsOptions)(relationsGraph);
171
200
  }, [relationsGraph, formatMessage]);
172
201
 
202
+ const showGraph = graphRender && !_.isEmpty(currentId);
203
+ const stableGraphKey = useMemo(
204
+ () =>
205
+ `graph-${currentId}-${depth}-${_.sortBy(_.identity, traversalTags).join(",")}`,
206
+ [currentId, depth, traversalTags]
207
+ );
208
+
173
209
  return (
174
- <Segment attached="bottom">
175
- <div className="traversal-tags-container">
176
- <div>
177
- <b>
178
- <label>
179
- {formatMessage({
180
- id: "relations.tags.label",
181
- })}
182
- </label>
183
- </b>
184
- </div>
185
- <div>
186
- <Dropdown
187
- className="traversal-tags"
188
- placeholder={formatMessage({
189
- id: `relations.tags.placeholder`,
190
- })}
191
- multiple
192
- search
193
- selection
194
- options={tagOptions}
195
- onChange={onTraversalTagsChange}
196
- clearable
197
- />
198
- </div>
199
- </div>
210
+ <Segment
211
+ attached="bottom"
212
+ className={`concept-relations-segment${showGraph ? " relation-graph-segment" : ""}`}
213
+ >
200
214
  <div className="implementation-actions">
201
215
  {_.negate(_.isEmpty)(conceptRelations) ? (
202
216
  <Button.Group>
@@ -226,21 +240,28 @@ export const ConceptRelations = ({
226
240
  </div>
227
241
  {conceptRelations ? (
228
242
  <>
229
- {graphRender && !_.isEmpty(currentId) ? (
230
- <>
231
- <RelationGraphDepth
232
- onClick={(_e) => setDepth(maxDepth)}
233
- onChange={(newDepth) => setDepth(parseInt(newDepth))}
234
- depth={depth}
235
- maxDepth={maxDepth}
236
- />
243
+ {showGraph ? (
244
+ <div className="relation-graph-wrapper">
245
+ <SliderWithFilter
246
+ tagOptions={tagOptions}
247
+ traversalTags={localTraversalTags}
248
+ onTraversalTagsChange={onTraversalTagsChange}
249
+ colorMap={colorMap}
250
+ >
251
+ <RelationGraphDepth
252
+ onChange={(newDepth) => setSelectedDepth(parseInt(newDepth, 10))}
253
+ depth={depth}
254
+ maxDepth={maxDepth}
255
+ />
256
+ </SliderWithFilter>
237
257
  <RelationGraph
238
- key={`graph-${depth}-${_.size(limitedRelationGraph?.nodes)}`}
258
+ key={stableGraphKey}
239
259
  navigate={navigateToConcept}
240
260
  currentId={currentId}
241
261
  relationsGraph={limitedRelationGraph}
262
+ colorMap={colorMap}
242
263
  />
243
- </>
264
+ </div>
244
265
  ) : (
245
266
  <Table selectable>
246
267
  <Table.Body>
@@ -0,0 +1,198 @@
1
+ import { useState, useCallback } from "react";
2
+ import PropTypes from "prop-types";
3
+ import { useIntl } from "react-intl";
4
+
5
+ const FunnelIcon = () => (
6
+ <svg
7
+ width="14"
8
+ height="14"
9
+ viewBox="0 0 24 24"
10
+ fill="none"
11
+ stroke="#ff5c00"
12
+ strokeWidth="2.5"
13
+ strokeLinecap="round"
14
+ strokeLinejoin="round"
15
+ >
16
+ <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
17
+ </svg>
18
+ );
19
+
20
+ const XIcon = () => (
21
+ <svg
22
+ width="14"
23
+ height="14"
24
+ viewBox="0 0 24 24"
25
+ fill="none"
26
+ stroke="#999"
27
+ strokeWidth="2.5"
28
+ strokeLinecap="round"
29
+ strokeLinejoin="round"
30
+ >
31
+ <line x1="18" y1="6" x2="6" y2="18" />
32
+ <line x1="6" y1="6" x2="18" y2="18" />
33
+ </svg>
34
+ );
35
+
36
+ const SquareIcon = ({ color = "#333" }) => (
37
+ <svg
38
+ width="16"
39
+ height="16"
40
+ viewBox="0 0 24 24"
41
+ fill="none"
42
+ stroke={color}
43
+ strokeWidth="2"
44
+ strokeLinecap="round"
45
+ strokeLinejoin="round"
46
+ >
47
+ <rect x="3" y="3" width="18" height="18" rx="2" />
48
+ </svg>
49
+ );
50
+
51
+ const SquareCheckIcon = ({ color = "#f87201" }) => (
52
+ <svg
53
+ width="16"
54
+ height="16"
55
+ viewBox="0 0 24 24"
56
+ fill="none"
57
+ stroke="#ffffff"
58
+ strokeWidth="2"
59
+ strokeLinecap="round"
60
+ strokeLinejoin="round"
61
+ >
62
+ <rect x="3" y="3" width="18" height="18" rx="2" fill={color} stroke={color} />
63
+ <polyline points="9 12 11 14 15 10" />
64
+ </svg>
65
+ );
66
+
67
+ const Badge = ({ count }) => (
68
+ <span className="slider-with-filter__badge">
69
+ <span className="slider-with-filter__badge-text">{count}</span>
70
+ </span>
71
+ );
72
+
73
+ const TagChip = ({ text, selected, onClick, color }) => (
74
+ <span
75
+ className="slider-with-filter__chip"
76
+ role="option"
77
+ aria-selected={selected}
78
+ onClick={onClick}
79
+ title={text}
80
+ >
81
+ {selected ? <SquareCheckIcon color={color} /> : <SquareIcon color={color} />}
82
+ <span
83
+ className="slider-with-filter__chip-text"
84
+ style={color ? { "--td-chip-color": color } : undefined}
85
+ >
86
+ {text}
87
+ </span>
88
+ </span>
89
+ );
90
+
91
+ const SliderWithFilter = ({
92
+ tagOptions = [],
93
+ traversalTags = [],
94
+ onTraversalTagsChange,
95
+ colorMap,
96
+ children,
97
+ }) => {
98
+ const { formatMessage } = useIntl();
99
+ const [isOpen, setIsOpen] = useState(false);
100
+
101
+ const hasApplied = traversalTags.length > 0;
102
+
103
+ const handleToggleOpen = useCallback(() => setIsOpen((o) => !o), []);
104
+
105
+ const handleClearFilters = useCallback(
106
+ (e) => {
107
+ e.stopPropagation();
108
+ onTraversalTagsChange?.(null, { value: [] });
109
+ },
110
+ [onTraversalTagsChange]
111
+ );
112
+
113
+ const handleToggleTag = useCallback(
114
+ (tagValue) => {
115
+ const next = traversalTags.includes(tagValue)
116
+ ? traversalTags.filter((t) => t !== tagValue)
117
+ : [...traversalTags, tagValue];
118
+ onTraversalTagsChange?.(null, { value: next });
119
+ },
120
+ [traversalTags, onTraversalTagsChange]
121
+ );
122
+
123
+ return (
124
+ <div className="slider-with-filter__panel">
125
+ <div className="slider-with-filter__header-area">
126
+ <div
127
+ className="slider-with-filter__tab"
128
+ onClick={handleToggleOpen}
129
+ title={formatMessage({ id: "relations.tags.filter" })}
130
+ >
131
+ <span className="slider-with-filter__filtro-label">
132
+ {formatMessage({ id: "relations.tags.filter.label" })}
133
+ </span>
134
+ <span className="slider-with-filter__tab-actions">
135
+ <FunnelIcon />
136
+ {hasApplied ? <Badge count={traversalTags.length} /> : null}
137
+ </span>
138
+ </div>
139
+
140
+ {isOpen && tagOptions.length > 0 && (
141
+ <div className="slider-with-filter__chips-bar">
142
+ <div className="slider-with-filter__chips-list">
143
+ {tagOptions.map((opt) => (
144
+ <TagChip
145
+ key={opt.value}
146
+ text={opt.text}
147
+ selected={traversalTags.includes(opt.value)}
148
+ color={opt.type && colorMap ? colorMap.get(opt.type) : undefined}
149
+ onClick={(e) => {
150
+ e.stopPropagation();
151
+ handleToggleTag(opt.value);
152
+ }}
153
+ />
154
+ ))}
155
+ </div>
156
+ </div>
157
+ )}
158
+
159
+ </div>
160
+
161
+ <div className="slider-with-filter__slider-area">
162
+ {children}
163
+ <div className="slider-with-filter__prof-pill">
164
+ <span className="slider-with-filter__prof-label">
165
+ {formatMessage({ id: "relations.depth.label" })}
166
+ </span>
167
+ </div>
168
+ </div>
169
+
170
+ {hasApplied && (
171
+ <span
172
+ className="slider-with-filter__clear"
173
+ onClick={handleClearFilters}
174
+ >
175
+ <XIcon />
176
+ <span className="slider-with-filter__clear-label">
177
+ {formatMessage({ id: "relations.tags.clear" })}
178
+ </span>
179
+ </span>
180
+ )}
181
+ </div>
182
+ );
183
+ };
184
+
185
+ SliderWithFilter.propTypes = {
186
+ tagOptions: PropTypes.arrayOf(
187
+ PropTypes.shape({
188
+ text: PropTypes.string,
189
+ value: PropTypes.any,
190
+ })
191
+ ),
192
+ traversalTags: PropTypes.array,
193
+ onTraversalTagsChange: PropTypes.func,
194
+ colorMap: PropTypes.instanceOf(Map),
195
+ children: PropTypes.node,
196
+ };
197
+
198
+ export default SliderWithFilter;
@@ -1,9 +1,11 @@
1
+ import { act, fireEvent, waitFor } from "@testing-library/react";
1
2
  import { render, waitForLoad } from "@truedat/test/render";
2
3
  import { ConceptRelations } from "../ConceptRelations";
3
4
  import { setConfig } from "@truedat/core/truedatConfig";
4
5
 
5
6
  const mockUseLocation = jest.fn(() => ({ pathname: "/bar" }));
6
7
  const mockUseNavigate = jest.fn(() => jest.fn());
8
+ let lastRelationGraphProps;
7
9
 
8
10
  jest.mock("react-router", () => ({
9
11
  ...jest.requireActual("react-router"),
@@ -12,9 +14,30 @@ jest.mock("react-router", () => ({
12
14
  }));
13
15
 
14
16
  jest.mock("@truedat/lm/components", () => ({
15
- RelationGraph: () => <div data-testid="relation-graph">RelationGraph</div>,
16
- RelationGraphDepth: () => (
17
- <div data-testid="relation-graph-depth">RelationGraphDepth</div>
17
+ RelationGraph: (props) => {
18
+ lastRelationGraphProps = props;
19
+ const nodeIds = (props.relationsGraph?.nodes || [])
20
+ .map(({ id }) => id)
21
+ .join(",");
22
+ const edgeIds = (props.relationsGraph?.edges || [])
23
+ .map(({ id }) => id)
24
+ .join(",");
25
+
26
+ return (
27
+ <div data-testid="relation-graph">
28
+ <span data-testid="relation-graph-node-ids">{nodeIds}</span>
29
+ <span data-testid="relation-graph-edge-ids">{edgeIds}</span>
30
+ </div>
31
+ );
32
+ },
33
+ RelationGraphDepth: ({ onChange, depth, maxDepth }) => (
34
+ <button
35
+ type="button"
36
+ data-testid="relation-graph-depth"
37
+ onClick={() => onChange(1)}
38
+ >
39
+ {`${depth}/${maxDepth}`}
40
+ </button>
18
41
  ),
19
42
  }));
20
43
 
@@ -22,6 +45,7 @@ describe("<ConceptRelations />", () => {
22
45
  beforeEach(() => {
23
46
  setConfig({});
24
47
  mockUseLocation.mockReturnValue({ pathname: "/bar" });
48
+ lastRelationGraphProps = undefined;
25
49
  });
26
50
 
27
51
  afterEach(() => {
@@ -52,6 +76,28 @@ describe("<ConceptRelations />", () => {
52
76
  visible: true,
53
77
  };
54
78
 
79
+ const graphWithDepthAndTags = {
80
+ nodes: [
81
+ { id: "business_concept:1", name: "Root" },
82
+ { id: "business_concept:2", name: "Second" },
83
+ { id: "business_concept:3", name: "Third" },
84
+ ],
85
+ edges: [
86
+ {
87
+ id: "root_to_second",
88
+ source_id: "business_concept:1",
89
+ target_id: "business_concept:2",
90
+ tags: [{ id: "foo", value: { type: "foo" } }],
91
+ },
92
+ {
93
+ id: "second_to_third",
94
+ source_id: "business_concept:2",
95
+ target_id: "business_concept:3",
96
+ tags: [{ id: "bar", value: { type: "bar" } }],
97
+ },
98
+ ],
99
+ };
100
+
55
101
  it("matches the latest snapshot", async () => {
56
102
  const props = {
57
103
  ...baseProps,
@@ -66,6 +112,42 @@ describe("<ConceptRelations />", () => {
66
112
  expect(rendered.container).toMatchSnapshot();
67
113
  });
68
114
 
115
+ it("matches snapshot with list view and relations", async () => {
116
+ setConfig({ RelatedConceptsActiveRenderMode: "list" });
117
+
118
+ const props = {
119
+ ...baseProps,
120
+ conceptRelations: [
121
+ {
122
+ id: 1,
123
+ source_id: 1,
124
+ target_id: 2,
125
+ tags: [{ id: 3, value: { type: "related_to" } }],
126
+ context: {
127
+ source: { id: 1, name: "Source Concept" },
128
+ target: { id: 2, name: "Target Concept" },
129
+ },
130
+ },
131
+ {
132
+ id: 2,
133
+ source_id: 1,
134
+ target_id: 3,
135
+ tags: [],
136
+ context: {
137
+ source: { id: 1, name: "Source Concept" },
138
+ target: { id: 3, name: "Another Target" },
139
+ },
140
+ },
141
+ ],
142
+ };
143
+
144
+ const rendered = render(<ConceptRelations {...props} />, {
145
+ state: baseState,
146
+ });
147
+ await waitForLoad(rendered);
148
+ expect(rendered.container).toMatchSnapshot();
149
+ });
150
+
69
151
  it("activates graph button when RelatedConceptsActiveRenderMode is graph", async () => {
70
152
  setConfig({ RelatedConceptsActiveRenderMode: "graph" });
71
153
 
@@ -75,12 +157,18 @@ describe("<ConceptRelations />", () => {
75
157
  await waitForLoad(rendered);
76
158
 
77
159
  expect(
78
- rendered.container.querySelector('button[data-tooltip*="maps"]')
160
+ rendered.container.querySelector('button[data-tooltip*="maps"]'),
79
161
  ).toHaveClass("active");
80
162
  expect(
81
- rendered.container.querySelector('button[data-tooltip*="list"]')
163
+ rendered.container.querySelector('button[data-tooltip*="list"]'),
82
164
  ).not.toHaveClass("active");
165
+ expect(
166
+ rendered.container.querySelector(".ui.bottom.attached.segment"),
167
+ ).toHaveClass("concept-relations-segment");
83
168
  expect(rendered.getByTestId("relation-graph")).toBeInTheDocument();
169
+ expect(
170
+ rendered.container.querySelector(".ui.bottom.attached.segment"),
171
+ ).toHaveClass("relation-graph-segment");
84
172
  });
85
173
 
86
174
  it("activates list button when RelatedConceptsActiveRenderMode is list", async () => {
@@ -92,12 +180,21 @@ describe("<ConceptRelations />", () => {
92
180
  await waitForLoad(rendered);
93
181
 
94
182
  expect(
95
- rendered.container.querySelector('button[data-tooltip*="list"]')
183
+ rendered.container.querySelector('button[data-tooltip*="list"]'),
96
184
  ).toHaveClass("active");
97
185
  expect(
98
- rendered.container.querySelector('button[data-tooltip*="maps"]')
186
+ rendered.container.querySelector('button[data-tooltip*="maps"]'),
99
187
  ).not.toHaveClass("active");
100
188
  expect(rendered.queryByTestId("relation-graph")).not.toBeInTheDocument();
189
+ expect(
190
+ rendered.container.querySelector(".ui.bottom.attached.segment"),
191
+ ).toHaveClass("concept-relations-segment");
192
+ expect(
193
+ rendered.container.querySelector(".ui.bottom.attached.segment"),
194
+ ).not.toHaveClass("relation-graph-segment");
195
+ expect(
196
+ rendered.container.querySelector(".slider-with-filter__panel"),
197
+ ).not.toBeInTheDocument();
101
198
  });
102
199
 
103
200
  it("shows graph view by default when RelatedConceptsActiveRenderMode is not set", async () => {
@@ -109,10 +206,10 @@ describe("<ConceptRelations />", () => {
109
206
  await waitForLoad(rendered);
110
207
 
111
208
  expect(
112
- rendered.container.querySelector('button[data-tooltip*="maps"]')
209
+ rendered.container.querySelector('button[data-tooltip*="maps"]'),
113
210
  ).toHaveClass("active");
114
211
  expect(
115
- rendered.container.querySelector('button[data-tooltip*="list"]')
212
+ rendered.container.querySelector('button[data-tooltip*="list"]'),
116
213
  ).not.toHaveClass("active");
117
214
  expect(rendered.getByTestId("relation-graph")).toBeInTheDocument();
118
215
  });
@@ -130,10 +227,10 @@ describe("<ConceptRelations />", () => {
130
227
  await waitForLoad(rendered);
131
228
 
132
229
  expect(
133
- rendered.container.querySelector('button[data-tooltip*="list"]')
230
+ rendered.container.querySelector('button[data-tooltip*="list"]'),
134
231
  ).toHaveClass("active");
135
232
  expect(
136
- rendered.container.querySelector('button[data-tooltip*="maps"]')
233
+ rendered.container.querySelector('button[data-tooltip*="maps"]'),
137
234
  ).not.toHaveClass("active");
138
235
  expect(rendered.queryByTestId("relation-graph")).not.toBeInTheDocument();
139
236
  });
@@ -151,10 +248,10 @@ describe("<ConceptRelations />", () => {
151
248
  await waitForLoad(rendered);
152
249
 
153
250
  expect(
154
- rendered.container.querySelector('button[data-tooltip*="maps"]')
251
+ rendered.container.querySelector('button[data-tooltip*="maps"]'),
155
252
  ).toHaveClass("active");
156
253
  expect(
157
- rendered.container.querySelector('button[data-tooltip*="list"]')
254
+ rendered.container.querySelector('button[data-tooltip*="list"]'),
158
255
  ).not.toHaveClass("active");
159
256
  expect(rendered.getByTestId("relation-graph")).toBeInTheDocument();
160
257
  });
@@ -173,14 +270,61 @@ describe("<ConceptRelations />", () => {
173
270
  await waitForLoad(rendered);
174
271
 
175
272
  expect(
176
- rendered.container.querySelector('button[data-tooltip*="maps"]')
273
+ rendered.container.querySelector('button[data-tooltip*="maps"]'),
177
274
  ).not.toBeInTheDocument();
178
275
 
179
276
  rendered.rerender(<ConceptRelations {...baseProps} />);
180
277
  await waitForLoad(rendered);
181
278
 
182
279
  expect(
183
- rendered.container.querySelector('button[data-tooltip*="list"]')
280
+ rendered.container.querySelector('button[data-tooltip*="list"]'),
184
281
  ).toHaveClass("active");
185
282
  });
283
+
284
+ it("keeps the selected depth when clearing relation filters", async () => {
285
+ setConfig({ RelatedConceptsTagsDebounceMs: 0 });
286
+
287
+ const rendered = render(
288
+ <ConceptRelations
289
+ {...baseProps}
290
+ relationsGraph={graphWithDepthAndTags}
291
+ />,
292
+ {
293
+ state: baseState,
294
+ },
295
+ );
296
+
297
+ await waitForLoad(rendered);
298
+
299
+ fireEvent.click(rendered.getByTestId("relation-graph-depth"));
300
+
301
+ await waitFor(() => {
302
+ expect(lastRelationGraphProps.relationsGraph.nodes).toHaveLength(2);
303
+ expect(lastRelationGraphProps.relationsGraph.edges).toHaveLength(1);
304
+ });
305
+
306
+ fireEvent.click(rendered.getByText("relations.tags.filter.label"));
307
+ fireEvent.click(rendered.getByText("foo"));
308
+ act(() => {
309
+ jest.runOnlyPendingTimers();
310
+ });
311
+
312
+ await waitFor(() => {
313
+ expect(lastRelationGraphProps.relationsGraph.nodes).toHaveLength(2);
314
+ expect(lastRelationGraphProps.relationsGraph.edges).toHaveLength(1);
315
+ });
316
+
317
+ fireEvent.click(rendered.getByText("relations.tags.clear"));
318
+ act(() => {
319
+ jest.runOnlyPendingTimers();
320
+ });
321
+
322
+ await waitFor(() => {
323
+ expect(lastRelationGraphProps.relationsGraph.nodes).toHaveLength(2);
324
+ expect(lastRelationGraphProps.relationsGraph.edges).toHaveLength(1);
325
+ expect(
326
+ lastRelationGraphProps.relationsGraph.nodes.map(({ id }) => id),
327
+ ).toEqual(["business_concept:1", "business_concept:2"]);
328
+ });
329
+ });
186
330
  });
@@ -0,0 +1,134 @@
1
+ import { fireEvent, screen } from "@testing-library/react";
2
+ import { render } from "@truedat/test/render";
3
+ import SliderWithFilter from "../SliderWithFilter";
4
+
5
+ const tagOptions = [
6
+ { text: "Type A", value: "tag-a", type: "type_a" },
7
+ { text: "Type B", value: "tag-b", type: "type_b" },
8
+ ];
9
+
10
+ const defaultProps = {
11
+ tagOptions,
12
+ traversalTags: [],
13
+ onTraversalTagsChange: jest.fn(),
14
+ };
15
+
16
+ describe("<SliderWithFilter />", () => {
17
+ beforeEach(() => {
18
+ jest.clearAllMocks();
19
+ });
20
+
21
+ it("renders correctly", () => {
22
+ const { container } = render(<SliderWithFilter {...defaultProps} />);
23
+
24
+ expect(container).toMatchSnapshot();
25
+ });
26
+
27
+ it("renders the filter button", () => {
28
+ render(<SliderWithFilter {...defaultProps} />);
29
+
30
+ expect(screen.getByText("relations.tags.filter.label")).toBeInTheDocument();
31
+ });
32
+
33
+ it("does not show badge when no filters are applied", () => {
34
+ render(<SliderWithFilter {...defaultProps} />);
35
+
36
+ expect(screen.queryByText("0")).not.toBeInTheDocument();
37
+ });
38
+
39
+ it("shows badge with count when filters are applied", () => {
40
+ render(
41
+ <SliderWithFilter {...defaultProps} traversalTags={["tag-a", "tag-b"]} />
42
+ );
43
+
44
+ expect(screen.getByText("2")).toBeInTheDocument();
45
+ });
46
+
47
+ it("does not show chips when closed", () => {
48
+ render(<SliderWithFilter {...defaultProps} />);
49
+
50
+ expect(screen.queryByText("Type A")).not.toBeInTheDocument();
51
+ expect(screen.queryByText("Type B")).not.toBeInTheDocument();
52
+ });
53
+
54
+ it("shows chips when filter button is clicked", () => {
55
+ render(<SliderWithFilter {...defaultProps} />);
56
+
57
+ fireEvent.click(screen.getByText("relations.tags.filter.label"));
58
+
59
+ expect(screen.getByText("Type A")).toBeInTheDocument();
60
+ expect(screen.getByText("Type B")).toBeInTheDocument();
61
+ });
62
+
63
+ it("calls onTraversalTagsChange with selected tag when chip is clicked", () => {
64
+ const onTraversalTagsChange = jest.fn();
65
+ render(
66
+ <SliderWithFilter
67
+ {...defaultProps}
68
+ onTraversalTagsChange={onTraversalTagsChange}
69
+ />
70
+ );
71
+
72
+ fireEvent.click(screen.getByText("relations.tags.filter.label"));
73
+ fireEvent.click(screen.getByText("Type A"));
74
+
75
+ expect(onTraversalTagsChange).toHaveBeenCalledWith(null, {
76
+ value: ["tag-a"],
77
+ });
78
+ });
79
+
80
+ it("calls onTraversalTagsChange removing tag when selected chip is clicked", () => {
81
+ const onTraversalTagsChange = jest.fn();
82
+ render(
83
+ <SliderWithFilter
84
+ {...defaultProps}
85
+ traversalTags={["tag-a"]}
86
+ onTraversalTagsChange={onTraversalTagsChange}
87
+ />
88
+ );
89
+
90
+ fireEvent.click(screen.getByText("relations.tags.filter.label"));
91
+ fireEvent.click(screen.getByText("Type A"));
92
+
93
+ expect(onTraversalTagsChange).toHaveBeenCalledWith(null, { value: [] });
94
+ });
95
+
96
+ it("shows clear button when filters are applied", () => {
97
+ render(
98
+ <SliderWithFilter {...defaultProps} traversalTags={["tag-a"]} />
99
+ );
100
+
101
+ expect(screen.getByText("relations.tags.clear")).toBeInTheDocument();
102
+ });
103
+
104
+ it("does not show clear button when no filters are applied", () => {
105
+ render(<SliderWithFilter {...defaultProps} />);
106
+
107
+ expect(screen.queryByText("relations.tags.clear")).not.toBeInTheDocument();
108
+ });
109
+
110
+ it("calls onTraversalTagsChange with empty array when clear button is clicked", () => {
111
+ const onTraversalTagsChange = jest.fn();
112
+ render(
113
+ <SliderWithFilter
114
+ {...defaultProps}
115
+ traversalTags={["tag-a"]}
116
+ onTraversalTagsChange={onTraversalTagsChange}
117
+ />
118
+ );
119
+
120
+ fireEvent.click(screen.getByText("relations.tags.clear"));
121
+
122
+ expect(onTraversalTagsChange).toHaveBeenCalledWith(null, { value: [] });
123
+ });
124
+
125
+ it("renders children in the slider area", () => {
126
+ render(
127
+ <SliderWithFilter {...defaultProps}>
128
+ <div data-testid="depth-slider">slider</div>
129
+ </SliderWithFilter>
130
+ );
131
+
132
+ expect(screen.getByTestId("depth-slider")).toBeInTheDocument();
133
+ });
134
+ });
@@ -1,71 +1,105 @@
1
1
  // Jest Snapshot v1, https://goo.gl/fbAQLP
2
2
 
3
- exports[`<ConceptRelations /> matches the latest snapshot 1`] = `
3
+ exports[`<ConceptRelations /> matches snapshot with list view and relations 1`] = `
4
4
  <div>
5
5
  <div
6
- class="ui bottom attached segment"
6
+ class="ui bottom attached segment concept-relations-segment"
7
7
  >
8
8
  <div
9
- class="traversal-tags-container"
9
+ class="implementation-actions"
10
10
  >
11
- <div>
12
- <b>
13
- <label>
14
- relations.tags.label
15
- </label>
16
- </b>
17
- </div>
18
- <div>
19
- <div
20
- aria-expanded="false"
21
- class="ui multiple search selection dropdown traversal-tags"
22
- role="combobox"
11
+ <div
12
+ class="ui buttons"
13
+ >
14
+ <button
15
+ class="ui icon button"
16
+ data-tooltip="relations.actions.maps.tooltip"
23
17
  >
24
- <input
25
- aria-autocomplete="list"
26
- autocomplete="off"
27
- class="search"
28
- tabindex="0"
29
- type="text"
30
- value=""
31
- />
32
- <span
33
- class="sizer"
18
+ <i
19
+ aria-hidden="true"
20
+ class="sitemap icon"
34
21
  />
35
- <div
36
- aria-atomic="true"
37
- aria-live="polite"
38
- class="divider default text"
39
- role="alert"
40
- >
41
- relations.tags.placeholder
42
- </div>
22
+ </button>
23
+ <button
24
+ class="ui active icon button"
25
+ data-tooltip="relations.actions.list.tooltip"
26
+ >
43
27
  <i
44
28
  aria-hidden="true"
45
- class="dropdown icon"
29
+ class="list ul icon"
46
30
  />
47
- <div
48
- aria-multiselectable="true"
49
- class="menu transition"
50
- role="listbox"
31
+ </button>
32
+ </div>
33
+ </div>
34
+ <table
35
+ class="ui selectable table"
36
+ >
37
+ <tbody
38
+ class=""
39
+ >
40
+ <tr
41
+ class=""
42
+ >
43
+ <td
44
+ class=""
45
+ >
46
+ Source Concept
47
+ </td>
48
+ <td
49
+ class="center aligned"
51
50
  >
52
51
  <div
53
- aria-checked="false"
54
- aria-selected="true"
55
- class="selected item"
56
- role="option"
57
- style="pointer-events: all;"
52
+ class="ui label"
58
53
  >
59
- <span
60
- class="text"
61
- >
62
- _empty
63
- </span>
54
+ related_to
64
55
  </div>
65
- </div>
66
- </div>
67
- </div>
68
- </div>
56
+ </td>
57
+ <td
58
+ class="center aligned"
59
+ >
60
+ Target Concept
61
+ </td>
62
+ <td
63
+ class="center aligned"
64
+ />
65
+ </tr>
66
+ <tr
67
+ class=""
68
+ >
69
+ <td
70
+ class=""
71
+ >
72
+ Source Concept
73
+ </td>
74
+ <td
75
+ class="center aligned"
76
+ >
77
+ <div
78
+ class="ui label"
79
+ >
80
+ -
81
+ </div>
82
+ </td>
83
+ <td
84
+ class="center aligned"
85
+ >
86
+ Another Target
87
+ </td>
88
+ <td
89
+ class="center aligned"
90
+ />
91
+ </tr>
92
+ </tbody>
93
+ </table>
94
+ </div>
95
+ </div>
96
+ `;
97
+
98
+ exports[`<ConceptRelations /> matches the latest snapshot 1`] = `
99
+ <div>
100
+ <div
101
+ class="ui bottom attached segment concept-relations-segment"
102
+ >
69
103
  <div
70
104
  class="implementation-actions"
71
105
  />
@@ -0,0 +1,55 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`<SliderWithFilter /> renders correctly 1`] = `
4
+ <div>
5
+ <div
6
+ class="slider-with-filter__panel"
7
+ >
8
+ <div
9
+ class="slider-with-filter__header-area"
10
+ >
11
+ <div
12
+ class="slider-with-filter__tab"
13
+ title="relations.tags.filter"
14
+ >
15
+ <span
16
+ class="slider-with-filter__filtro-label"
17
+ >
18
+ relations.tags.filter.label
19
+ </span>
20
+ <span
21
+ class="slider-with-filter__tab-actions"
22
+ >
23
+ <svg
24
+ fill="none"
25
+ height="14"
26
+ stroke="#ff5c00"
27
+ stroke-linecap="round"
28
+ stroke-linejoin="round"
29
+ stroke-width="2.5"
30
+ viewBox="0 0 24 24"
31
+ width="14"
32
+ >
33
+ <polygon
34
+ points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"
35
+ />
36
+ </svg>
37
+ </span>
38
+ </div>
39
+ </div>
40
+ <div
41
+ class="slider-with-filter__slider-area"
42
+ >
43
+ <div
44
+ class="slider-with-filter__prof-pill"
45
+ >
46
+ <span
47
+ class="slider-with-filter__prof-label"
48
+ >
49
+ relations.depth.label
50
+ </span>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </div>
55
+ `;
@@ -0,0 +1,202 @@
1
+ @slider-width: 104px;
2
+ @tab-offset: 8px;
3
+ @tab-width: 88px;
4
+ @tab-top: 13px;
5
+ @tab-height: 24px;
6
+ @orange-primary: #ed5c17;
7
+ @orange-bg: #FFF7F2;
8
+ @orange-border: #FFE4D1;
9
+ @font-stack: 'Funnel Sans', Inter, sans-serif;
10
+
11
+ .relation-graph-wrapper {
12
+ .slider-with-filter__panel {
13
+ position: absolute;
14
+ top: 44px;
15
+ right: 8px;
16
+ z-index: 2;
17
+ }
18
+ }
19
+
20
+ .slider-with-filter {
21
+ &__panel {
22
+ width: @slider-width;
23
+ min-height: 220px;
24
+ background: #ffffff9a;
25
+ backdrop-filter: blur(4px);
26
+ -webkit-backdrop-filter: blur(4px);
27
+ border-radius: 12px;
28
+ border: 1px solid #F3F4F6;
29
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.03), 0 16px 40px rgba(0, 0, 0, 0.07);
30
+ display: flex;
31
+ flex-direction: column;
32
+ overflow: visible;
33
+ position: relative;
34
+ }
35
+
36
+ &__header-area {
37
+ position: relative;
38
+ width: 100%;
39
+ height: 44px;
40
+ overflow: visible;
41
+ flex-shrink: 0;
42
+ }
43
+
44
+ &__tab {
45
+ position: absolute;
46
+ top: @tab-top;
47
+ right: @tab-offset;
48
+ width: @tab-width;
49
+ height: @tab-height;
50
+ background: @orange-bg;
51
+ border: 1px solid @orange-border;
52
+ border-radius: 9999px;
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: space-between;
56
+ gap: 8px;
57
+ padding: 0 8px;
58
+ cursor: pointer;
59
+ box-sizing: border-box;
60
+ white-space: nowrap;
61
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
62
+ }
63
+
64
+ &__tab-actions {
65
+ display: inline-flex;
66
+ align-items: center;
67
+ justify-content: flex-end;
68
+ gap: 2px;
69
+ min-width: 20px;
70
+ margin-left: auto;
71
+ flex-shrink: 0;
72
+ }
73
+
74
+ &__chips-bar {
75
+ position: absolute;
76
+ top: (@tab-top + (@tab-height / 2));
77
+ right: (@tab-width + @tab-offset + 6px);
78
+ display: flex;
79
+ flex-direction: column;
80
+ align-items: flex-start;
81
+ gap: 6px;
82
+ padding: 8px;
83
+ background: #ffffffe0;
84
+ backdrop-filter: blur(4px);
85
+ -webkit-backdrop-filter: blur(4px);
86
+ border: 1px solid @orange-border;
87
+ border-radius: 12px;
88
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
89
+ white-space: nowrap;
90
+ transform-origin: right center;
91
+ transform: translateY(-50%);
92
+ }
93
+
94
+ &__chips-list {
95
+ display: flex;
96
+ align-items: center;
97
+ gap: 6px;
98
+ }
99
+
100
+ &__filtro-label {
101
+ font-family: @font-stack;
102
+ font-size: 10px;
103
+ font-weight: 700;
104
+ letter-spacing: 0.3px;
105
+ color: @orange-primary;
106
+ flex-shrink: 0;
107
+ }
108
+
109
+ &__badge {
110
+ display: inline-flex;
111
+ align-items: center;
112
+ justify-content: flex-end;
113
+ min-width: 14px;
114
+ margin-left: -4px;
115
+ flex-shrink: 0;
116
+ pointer-events: none;
117
+ }
118
+
119
+ &__badge-text {
120
+ font-family: @font-stack;
121
+ font-size: 12px;
122
+ font-weight: bold;
123
+ color: #111111;
124
+ line-height: 1;
125
+ }
126
+
127
+ &__chip {
128
+ display: inline-flex;
129
+ align-items: center;
130
+ gap: 4px;
131
+ cursor: pointer;
132
+ flex-shrink: 0;
133
+ padding: 0 4px;
134
+ }
135
+
136
+ &__clear {
137
+ position: absolute;
138
+ left: 50%;
139
+ bottom: -36px;
140
+ transform: translateX(-50%);
141
+ display: inline-flex;
142
+ align-items: center;
143
+ gap: 4px;
144
+ cursor: pointer;
145
+ flex-shrink: 0;
146
+ opacity: 0.72;
147
+ padding: 4px 8px;
148
+ background: #fff;
149
+ border: 1px solid @orange-border;
150
+ border-radius: 9999px;
151
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
152
+ white-space: nowrap;
153
+ z-index: 1;
154
+
155
+ &:hover {
156
+ opacity: 1;
157
+ }
158
+ }
159
+
160
+ &__clear-label {
161
+ font-family: @font-stack;
162
+ font-size: 10px;
163
+ font-weight: 600;
164
+ color: #999;
165
+ white-space: nowrap;
166
+ }
167
+
168
+ &__chip-text {
169
+ font-family: @font-stack;
170
+ font-size: 12px;
171
+ font-weight: 600;
172
+ color: var(--td-chip-color, #666);
173
+ }
174
+
175
+ &__prof-pill {
176
+ width: 100%;
177
+ height: 18px;
178
+ display: flex;
179
+ align-items: center;
180
+ justify-content: center;
181
+ flex-shrink: 0;
182
+ margin-top: auto;
183
+ margin-bottom: 5px;
184
+ }
185
+
186
+ &__prof-label {
187
+ font-family: @font-stack;
188
+ font-size: 10px;
189
+ font-weight: 700;
190
+ color: @orange-primary;
191
+ line-height: 1;
192
+ }
193
+
194
+ &__slider-area {
195
+ flex: 1;
196
+ display: flex;
197
+ flex-direction: column;
198
+ align-items: center;
199
+ justify-content: flex-start;
200
+ padding: 14px 4px 8px;
201
+ }
202
+ }