@axinom/mosaic-ui 0.65.0-rc.3 → 0.65.0-rc.4
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/dist/components/List/ListRow/Renderers/TagsRenderer/TagsRenderer.d.ts +3 -0
- package/dist/components/List/ListRow/Renderers/TagsRenderer/TagsRenderer.d.ts.map +1 -0
- package/dist/components/List/ListRow/Renderers/index.d.ts +1 -0
- package/dist/components/List/ListRow/Renderers/index.d.ts.map +1 -1
- package/dist/components/Utils/Postgraphile/CreateConnectionRenderer.d.ts +3 -2
- package/dist/components/Utils/Postgraphile/CreateConnectionRenderer.d.ts.map +1 -1
- package/dist/index.es.js +4 -4
- package/dist/index.es.js.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/List/List.stories.tsx +19 -1
- package/src/components/List/ListRow/Renderers/TagsRenderer/TagsRenderer.scss +25 -0
- package/src/components/List/ListRow/Renderers/TagsRenderer/TagsRenderer.spec.tsx +154 -0
- package/src/components/List/ListRow/Renderers/TagsRenderer/TagsRenderer.tsx +139 -0
- package/src/components/List/ListRow/Renderers/index.ts +1 -0
- package/src/components/Utils/Postgraphile/CreateConnectionRenderer.spec.ts +259 -0
- package/src/components/Utils/Postgraphile/{CreateConnectionRenderer.ts → CreateConnectionRenderer.tsx} +18 -3
- package/src/styles/variables.scss +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axinom/mosaic-ui",
|
|
3
|
-
"version": "0.65.0-rc.
|
|
3
|
+
"version": "0.65.0-rc.4",
|
|
4
4
|
"description": "UI components for building Axinom Mosaic applications",
|
|
5
5
|
"author": "Axinom",
|
|
6
6
|
"license": "PROPRIETARY",
|
|
@@ -112,5 +112,5 @@
|
|
|
112
112
|
"publishConfig": {
|
|
113
113
|
"access": "public"
|
|
114
114
|
},
|
|
115
|
-
"gitHead": "
|
|
115
|
+
"gitHead": "dd33a828c6c8e435b04c880b71dca0a9297170a5"
|
|
116
116
|
}
|
|
@@ -12,12 +12,13 @@ import { List, ListProps } from './List';
|
|
|
12
12
|
import { Column, ColumnMap, ListSelectMode, SortData } from './List.model';
|
|
13
13
|
import { sortStoryData, useLocalSort } from './List.stories.helper';
|
|
14
14
|
import { ListRowProps } from './ListRow/ListRow';
|
|
15
|
-
import { createStateRenderer } from './ListRow/Renderers';
|
|
15
|
+
import { TagsRenderer, createStateRenderer } from './ListRow/Renderers';
|
|
16
16
|
|
|
17
17
|
interface ListStoryData {
|
|
18
18
|
id: number;
|
|
19
19
|
desc: string;
|
|
20
20
|
title: string;
|
|
21
|
+
tags: string[];
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
// Extracted the type into a new file, as Typescript has issues with the syntax
|
|
@@ -31,6 +32,7 @@ interface StateStoryData {
|
|
|
31
32
|
state: string;
|
|
32
33
|
title: string;
|
|
33
34
|
desc: string;
|
|
35
|
+
tags: string[];
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
type StateStoryType = ListProps<StateStoryData>;
|
|
@@ -49,8 +51,16 @@ const defaultColumns: Column<ListStoryData>[] = [
|
|
|
49
51
|
propertyName: 'desc',
|
|
50
52
|
label: 'Description',
|
|
51
53
|
},
|
|
54
|
+
{
|
|
55
|
+
propertyName: 'tags',
|
|
56
|
+
label: 'Tags',
|
|
57
|
+
sortable: false,
|
|
58
|
+
render: TagsRenderer,
|
|
59
|
+
},
|
|
52
60
|
];
|
|
53
61
|
|
|
62
|
+
const defaultTags = faker.lorem.words(10).split(' ');
|
|
63
|
+
|
|
54
64
|
const generateData = (amount: number): ListStoryData[] =>
|
|
55
65
|
generateItemArray(amount, (index) => ({
|
|
56
66
|
id: index + 1,
|
|
@@ -60,6 +70,10 @@ const generateData = (amount: number): ListStoryData[] =>
|
|
|
60
70
|
title: `Item ${index + 1}: ${faker.random.words(
|
|
61
71
|
faker.datatype.number({ min: 1, max: 3 }),
|
|
62
72
|
)}`,
|
|
73
|
+
tags: faker.helpers.arrayElements(
|
|
74
|
+
defaultTags,
|
|
75
|
+
faker.datatype.number({ min: 1, max: 10 }),
|
|
76
|
+
),
|
|
63
77
|
}));
|
|
64
78
|
|
|
65
79
|
const selectionOptions = enumToObj(ListSelectMode);
|
|
@@ -511,6 +525,10 @@ export const PagedData: StoryObj<StoryListType> = {
|
|
|
511
525
|
title: `Item ${index + dataIndexes}: ${faker.random.words(
|
|
512
526
|
faker.datatype.number({ min: 1, max: 3 }),
|
|
513
527
|
)}`,
|
|
528
|
+
tags: faker.helpers.arrayElements(
|
|
529
|
+
['Tag1', 'Tag2', 'Tag3', 'Tag4', 'Tag5'],
|
|
530
|
+
faker.datatype.number({ min: 1, max: 3 }),
|
|
531
|
+
),
|
|
514
532
|
}));
|
|
515
533
|
setMockIsLoading(false);
|
|
516
534
|
setData(sortStoryData(sort, [...data, ...newData]));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
@import '../../../../../styles/common.scss';
|
|
2
|
+
|
|
3
|
+
.container {
|
|
4
|
+
display: grid;
|
|
5
|
+
grid-auto-flow: column;
|
|
6
|
+
grid-auto-columns: min-content;
|
|
7
|
+
gap: 5px;
|
|
8
|
+
align-items: center;
|
|
9
|
+
|
|
10
|
+
.tag {
|
|
11
|
+
background-color: var(
|
|
12
|
+
--list-tag-background-color,
|
|
13
|
+
$list-tag-background-color
|
|
14
|
+
);
|
|
15
|
+
padding: 5px;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.overflowIndicator {
|
|
19
|
+
color: var(
|
|
20
|
+
--list-tag-overflow-background-color,
|
|
21
|
+
$list-tag-overflow-background-color
|
|
22
|
+
);
|
|
23
|
+
padding: 5px;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { TagsRenderer } from './TagsRenderer';
|
|
5
|
+
|
|
6
|
+
// Mock ResizeObserver
|
|
7
|
+
global.ResizeObserver = jest.fn().mockImplementation(() => ({
|
|
8
|
+
observe: jest.fn(),
|
|
9
|
+
unobserve: jest.fn(),
|
|
10
|
+
disconnect: jest.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
interface RendererWrapperProps {
|
|
14
|
+
/** Column data */
|
|
15
|
+
value: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const RendererWrapper: React.FC<RendererWrapperProps> = ({ value }) => {
|
|
19
|
+
return <>{TagsRenderer(value)}</>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const defaultProps: RendererWrapperProps = {
|
|
23
|
+
value: [],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe('TagsRenderer', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
// Reset mocks
|
|
29
|
+
jest.clearAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('renders the component without crashing', () => {
|
|
33
|
+
const { container } = render(<RendererWrapper value={['test']} />);
|
|
34
|
+
expect(container.firstChild).toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns empty content when value is not an array', () => {
|
|
38
|
+
const { container } = render(
|
|
39
|
+
<RendererWrapper {...defaultProps} value="not-an-array" />,
|
|
40
|
+
);
|
|
41
|
+
expect(container.firstChild).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns empty content when value is null', () => {
|
|
45
|
+
const { container } = render(
|
|
46
|
+
<RendererWrapper {...defaultProps} value={null} />,
|
|
47
|
+
);
|
|
48
|
+
expect(container.firstChild).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns empty content when value is undefined', () => {
|
|
52
|
+
const { container } = render(
|
|
53
|
+
<RendererWrapper {...defaultProps} value={undefined} />,
|
|
54
|
+
);
|
|
55
|
+
expect(container.firstChild).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('renders tags when array is provided', () => {
|
|
59
|
+
const mockTags = ['tag1', 'tag2', 'tag3'];
|
|
60
|
+
|
|
61
|
+
// Mock container width measurement
|
|
62
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
|
63
|
+
configurable: true,
|
|
64
|
+
value: 300, // Assume container width of 300px
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
render(<RendererWrapper {...defaultProps} value={mockTags} />);
|
|
68
|
+
|
|
69
|
+
// Since we're mocking ResizeObserver, simulate the callback
|
|
70
|
+
const resizeObserverCallback = (global.ResizeObserver as jest.Mock).mock
|
|
71
|
+
.calls[0]?.[0];
|
|
72
|
+
|
|
73
|
+
if (resizeObserverCallback) {
|
|
74
|
+
resizeObserverCallback([]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check that container exists
|
|
78
|
+
const container = screen.getByTestId('tags-container');
|
|
79
|
+
expect(container).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('displays overflow indicator when configured', () => {
|
|
83
|
+
const mockTags = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5'];
|
|
84
|
+
|
|
85
|
+
// Mock container and tag measurements to force overflow
|
|
86
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
|
87
|
+
configurable: true,
|
|
88
|
+
value: 100, // Small container width to force overflow
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Mock document.createElement to control tag width measurements
|
|
92
|
+
const originalCreateElement = document.createElement;
|
|
93
|
+
document.createElement = jest.fn().mockImplementation((tagName) => {
|
|
94
|
+
const element = originalCreateElement.call(document, tagName);
|
|
95
|
+
if (tagName === 'div') {
|
|
96
|
+
Object.defineProperty(element, 'offsetWidth', {
|
|
97
|
+
value: 50, // Each tag is 50px wide
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return element;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
render(<RendererWrapper {...defaultProps} value={mockTags} />);
|
|
104
|
+
|
|
105
|
+
// Simulate ResizeObserver callback
|
|
106
|
+
const resizeObserverCallback = (global.ResizeObserver as jest.Mock).mock
|
|
107
|
+
.calls[0]?.[0];
|
|
108
|
+
|
|
109
|
+
if (resizeObserverCallback) {
|
|
110
|
+
resizeObserverCallback([]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Test passes if no errors occur - the overflow logic depends on real DOM measurements
|
|
114
|
+
expect(screen.getByTestId('tags-container')).toBeInTheDocument();
|
|
115
|
+
|
|
116
|
+
// Restore
|
|
117
|
+
document.createElement = originalCreateElement;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('handles overflow behavior', () => {
|
|
121
|
+
const mockTags = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5'];
|
|
122
|
+
|
|
123
|
+
render(<RendererWrapper {...defaultProps} value={mockTags} />);
|
|
124
|
+
|
|
125
|
+
// Test that the component renders without errors
|
|
126
|
+
const container = screen.getByTestId('tags-container');
|
|
127
|
+
expect(container).toBeInTheDocument();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('handles empty array', () => {
|
|
131
|
+
render(<RendererWrapper {...defaultProps} value={[]} />);
|
|
132
|
+
|
|
133
|
+
// Should render container but with no tags
|
|
134
|
+
const container = screen.getByTestId('tags-container');
|
|
135
|
+
expect(container).toBeInTheDocument();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('cleans up ResizeObserver on unmount', () => {
|
|
139
|
+
const mockDisconnect = jest.fn();
|
|
140
|
+
(global.ResizeObserver as jest.Mock).mockImplementation(() => ({
|
|
141
|
+
observe: jest.fn(),
|
|
142
|
+
unobserve: jest.fn(),
|
|
143
|
+
disconnect: mockDisconnect,
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
const { unmount } = render(
|
|
147
|
+
<RendererWrapper {...defaultProps} value={['tag1', 'tag2']} />,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
unmount();
|
|
151
|
+
|
|
152
|
+
expect(mockDisconnect).toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import classes from './TagsRenderer.scss';
|
|
3
|
+
|
|
4
|
+
export const TagsRenderer = (val: unknown): JSX.Element => {
|
|
5
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
6
|
+
const tagRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
7
|
+
const [visibleItems, setVisibleItems] = useState<string[]>([]);
|
|
8
|
+
const [hiddenItems, setHiddenItems] = useState<string[]>([]);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!Array.isArray(val) || !containerRef.current) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (val.length === 0) {
|
|
16
|
+
setVisibleItems([]);
|
|
17
|
+
setHiddenItems([]);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const calculateVisibleItems = (): void => {
|
|
22
|
+
const container = containerRef.current;
|
|
23
|
+
if (!container || tagRefs.current.length === 0) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const containerWidth = container.offsetWidth;
|
|
28
|
+
const gap = 5; // Gap between items from SCSS
|
|
29
|
+
const overflowIndicatorWidth = 60; // Approximate width for "... +X"
|
|
30
|
+
|
|
31
|
+
// Measure actual tag widths using refs
|
|
32
|
+
const tagWidths: number[] = [];
|
|
33
|
+
tagRefs.current.forEach((tagRef) => {
|
|
34
|
+
if (tagRef) {
|
|
35
|
+
tagWidths.push(tagRef.offsetWidth);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Calculate how many items can fit
|
|
40
|
+
let totalWidth = 0;
|
|
41
|
+
let visibleCount = 0;
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < val.length; i++) {
|
|
44
|
+
const itemWidth = tagWidths[i] + (i > 0 ? gap : 0);
|
|
45
|
+
const wouldNeedOverflow = i < val.length - 1;
|
|
46
|
+
const requiredWidth =
|
|
47
|
+
totalWidth +
|
|
48
|
+
itemWidth +
|
|
49
|
+
(wouldNeedOverflow ? overflowIndicatorWidth + gap : 0);
|
|
50
|
+
|
|
51
|
+
if (requiredWidth <= containerWidth) {
|
|
52
|
+
totalWidth += itemWidth;
|
|
53
|
+
visibleCount++;
|
|
54
|
+
} else {
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// If we can't fit all items, reserve space for overflow indicator
|
|
60
|
+
if (visibleCount < val.length && visibleCount > 0) {
|
|
61
|
+
// Re-calculate with overflow indicator space reserved
|
|
62
|
+
totalWidth = 0;
|
|
63
|
+
visibleCount = 0;
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < val.length; i++) {
|
|
66
|
+
const itemWidth = tagWidths[i] + (i > 0 ? gap : 0);
|
|
67
|
+
const requiredWidth =
|
|
68
|
+
totalWidth + itemWidth + overflowIndicatorWidth + gap;
|
|
69
|
+
|
|
70
|
+
if (requiredWidth <= containerWidth && i < val.length - 1) {
|
|
71
|
+
totalWidth += itemWidth;
|
|
72
|
+
visibleCount++;
|
|
73
|
+
} else {
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
setVisibleItems(val.slice(0, visibleCount));
|
|
80
|
+
setHiddenItems(val.slice(visibleCount));
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Initial calculation after render
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
calculateVisibleItems();
|
|
86
|
+
}, 0);
|
|
87
|
+
|
|
88
|
+
const resizeObserver = new ResizeObserver(calculateVisibleItems);
|
|
89
|
+
resizeObserver.observe(containerRef.current);
|
|
90
|
+
|
|
91
|
+
return () => {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
resizeObserver.disconnect();
|
|
94
|
+
};
|
|
95
|
+
}, [val]);
|
|
96
|
+
|
|
97
|
+
if (!Array.isArray(val)) {
|
|
98
|
+
return <></>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const stringArray = val as string[];
|
|
102
|
+
|
|
103
|
+
// Create tooltip text for hidden items
|
|
104
|
+
const tooltipText = hiddenItems.length > 0 ? hiddenItems.join(', ') : '';
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div
|
|
108
|
+
ref={containerRef}
|
|
109
|
+
className={classes.container}
|
|
110
|
+
data-testid="tags-container"
|
|
111
|
+
title={stringArray.join(', ')}
|
|
112
|
+
>
|
|
113
|
+
{stringArray.map((item, index) => {
|
|
114
|
+
const isVisible = visibleItems.includes(item);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div
|
|
118
|
+
key={`${item}-${index}`}
|
|
119
|
+
ref={(el) => {
|
|
120
|
+
tagRefs.current[index] = el;
|
|
121
|
+
}}
|
|
122
|
+
className={classes.tag}
|
|
123
|
+
style={{
|
|
124
|
+
visibility: isVisible ? 'visible' : 'hidden',
|
|
125
|
+
position: isVisible ? 'static' : 'absolute',
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
{item}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
})}
|
|
132
|
+
{hiddenItems.length > 0 && (
|
|
133
|
+
<div className={classes.overflowIndicator} title={tooltipText}>
|
|
134
|
+
{`+${hiddenItems.length}`}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
};
|
|
@@ -2,4 +2,5 @@ export { BooleanDotRenderer } from './BooleanDotRenderer/BooleanDotRenderer';
|
|
|
2
2
|
export { DateRenderer } from './DateRenderer/DateRenderer';
|
|
3
3
|
export { createExternalLinkRenderer } from './ExternalLinkRenderer/ExternalLinkRenderer';
|
|
4
4
|
export { createStateRenderer } from './StateRenderer/StateRenderer';
|
|
5
|
+
export { TagsRenderer } from './TagsRenderer/TagsRenderer';
|
|
5
6
|
export { TimestampRenderer } from './TimestampRenderer/TimestampRenderer';
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { TagsRenderer } from '../../List';
|
|
2
|
+
import { createConnectionRenderer } from './CreateConnectionRenderer';
|
|
3
|
+
|
|
4
|
+
jest.mock('../../List', () => ({
|
|
5
|
+
TagsRenderer: jest.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
const mockTagsRenderer = TagsRenderer as jest.MockedFunction<
|
|
9
|
+
typeof TagsRenderer
|
|
10
|
+
>;
|
|
11
|
+
|
|
12
|
+
interface TestConnection {
|
|
13
|
+
nodes: { id: string; name: string }[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface StringConnection {
|
|
17
|
+
nodes: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('createConnectionRenderer', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
jest.clearAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('with renderAsTags = true (default)', () => {
|
|
26
|
+
it('should call TagsRenderer with mapped values when renderAsTags is true', () => {
|
|
27
|
+
const mockReturnValue = 'Mocked Tags JSX Element';
|
|
28
|
+
mockTagsRenderer.mockReturnValue(mockReturnValue as any);
|
|
29
|
+
|
|
30
|
+
const connection: TestConnection = {
|
|
31
|
+
nodes: [
|
|
32
|
+
{ id: '1', name: 'Item 1' },
|
|
33
|
+
{ id: '2', name: 'Item 2' },
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const selector = (item: { id: string; name: string }) => item.name;
|
|
38
|
+
const renderer = createConnectionRenderer<TestConnection>(selector);
|
|
39
|
+
const result = renderer(connection);
|
|
40
|
+
|
|
41
|
+
expect(mockTagsRenderer).toHaveBeenCalledWith(['Item 1', 'Item 2']);
|
|
42
|
+
expect(result).toBe(mockReturnValue);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should throw error when nodes is undefined', () => {
|
|
46
|
+
const connection = { nodes: undefined } as unknown as TestConnection;
|
|
47
|
+
const selector = (item: { id: string; name: string }) => item.name;
|
|
48
|
+
const renderer = createConnectionRenderer<TestConnection>(selector);
|
|
49
|
+
|
|
50
|
+
expect(() => renderer(connection)).toThrow();
|
|
51
|
+
expect(mockTagsRenderer).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should throw error when value is null', () => {
|
|
55
|
+
const selector = (item: { id: string; name: string }) => item.name;
|
|
56
|
+
const renderer = createConnectionRenderer<TestConnection>(selector);
|
|
57
|
+
|
|
58
|
+
expect(() => renderer(null)).toThrow();
|
|
59
|
+
expect(mockTagsRenderer).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should throw error when value is undefined', () => {
|
|
63
|
+
const selector = (item: { id: string; name: string }) => item.name;
|
|
64
|
+
const renderer = createConnectionRenderer<TestConnection>(selector);
|
|
65
|
+
|
|
66
|
+
expect(() => renderer(undefined)).toThrow();
|
|
67
|
+
expect(mockTagsRenderer).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should call TagsRenderer with empty array when nodes is empty', () => {
|
|
71
|
+
const mockReturnValue = 'Empty Tags JSX Element';
|
|
72
|
+
mockTagsRenderer.mockReturnValue(mockReturnValue as any);
|
|
73
|
+
|
|
74
|
+
const connection: TestConnection = { nodes: [] };
|
|
75
|
+
const selector = (item: { id: string; name: string }) => item.name;
|
|
76
|
+
const renderer = createConnectionRenderer<TestConnection>(selector);
|
|
77
|
+
const result = renderer(connection);
|
|
78
|
+
|
|
79
|
+
expect(mockTagsRenderer).toHaveBeenCalledWith([]);
|
|
80
|
+
expect(result).toBe(mockReturnValue);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('with renderAsTags = false', () => {
|
|
85
|
+
it('should return comma-separated string using selector', () => {
|
|
86
|
+
const connection: TestConnection = {
|
|
87
|
+
nodes: [
|
|
88
|
+
{ id: '1', name: 'Item 1' },
|
|
89
|
+
{ id: '2', name: 'Item 2' },
|
|
90
|
+
{ id: '3', name: 'Item 3' },
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const selector = (item: { id: string; name: string }) => item.name;
|
|
95
|
+
const renderer = createConnectionRenderer<TestConnection>(
|
|
96
|
+
selector,
|
|
97
|
+
false,
|
|
98
|
+
);
|
|
99
|
+
const result = renderer(connection);
|
|
100
|
+
|
|
101
|
+
expect(mockTagsRenderer).not.toHaveBeenCalled();
|
|
102
|
+
expect(result).toBe('Item 1, Item 2, Item 3');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should work with simple string arrays', () => {
|
|
106
|
+
const connection: StringConnection = {
|
|
107
|
+
nodes: ['apple', 'banana', 'cherry'],
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const selector = (item: string) => item;
|
|
111
|
+
const renderer = createConnectionRenderer<StringConnection>(
|
|
112
|
+
selector,
|
|
113
|
+
false,
|
|
114
|
+
);
|
|
115
|
+
const result = renderer(connection);
|
|
116
|
+
|
|
117
|
+
expect(result).toBe('apple, banana, cherry');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should work with complex selector functions', () => {
|
|
121
|
+
const connection: TestConnection = {
|
|
122
|
+
nodes: [
|
|
123
|
+
{ id: '1', name: 'Item 1' },
|
|
124
|
+
{ id: '2', name: 'Item 2' },
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const selector = (item: { id: string; name: string }, index: number) =>
|
|
129
|
+
`${index + 1}. ${item.name} (${item.id})`;
|
|
130
|
+
const renderer = createConnectionRenderer<TestConnection>(
|
|
131
|
+
selector,
|
|
132
|
+
false,
|
|
133
|
+
);
|
|
134
|
+
const result = renderer(connection);
|
|
135
|
+
|
|
136
|
+
expect(result).toBe('1. Item 1 (1), 2. Item 2 (2)');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should return empty string when nodes array is empty', () => {
|
|
140
|
+
const connection: TestConnection = { nodes: [] };
|
|
141
|
+
const selector = (item: { id: string; name: string }) => item.name;
|
|
142
|
+
const renderer = createConnectionRenderer<TestConnection>(
|
|
143
|
+
selector,
|
|
144
|
+
false,
|
|
145
|
+
);
|
|
146
|
+
const result = renderer(connection);
|
|
147
|
+
|
|
148
|
+
expect(result).toBe('');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should handle selector returning different types', () => {
|
|
152
|
+
interface NumberConnection {
|
|
153
|
+
nodes: { id: number; value: number }[];
|
|
154
|
+
}
|
|
155
|
+
const connection: NumberConnection = {
|
|
156
|
+
nodes: [
|
|
157
|
+
{ id: 1, value: 100 },
|
|
158
|
+
{ id: 2, value: 200 },
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const selector = (item: { id: number; value: number }) => item.value;
|
|
163
|
+
const renderer = createConnectionRenderer<NumberConnection>(
|
|
164
|
+
selector,
|
|
165
|
+
false,
|
|
166
|
+
);
|
|
167
|
+
const result = renderer(connection);
|
|
168
|
+
|
|
169
|
+
expect(result).toBe('100, 200');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should throw error when connection.nodes is undefined and renderAsTags is false', () => {
|
|
173
|
+
const connection = { nodes: undefined } as unknown as TestConnection;
|
|
174
|
+
const selector = (item: { id: string; name: string }) => item.name;
|
|
175
|
+
const renderer = createConnectionRenderer<TestConnection>(
|
|
176
|
+
selector,
|
|
177
|
+
false,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
expect(() => renderer(connection)).toThrow();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('edge cases', () => {
|
|
185
|
+
it('should handle nodes with special characters in comma-separated output', () => {
|
|
186
|
+
const connection: StringConnection = {
|
|
187
|
+
nodes: ['item, with comma', 'item with "quotes"', 'normal item'],
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const selector = (item: string) => item;
|
|
191
|
+
const renderer = createConnectionRenderer<StringConnection>(
|
|
192
|
+
selector,
|
|
193
|
+
false,
|
|
194
|
+
);
|
|
195
|
+
const result = renderer(connection);
|
|
196
|
+
|
|
197
|
+
expect(result).toBe('item, with comma, item with "quotes", normal item');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should handle selector returning empty strings', () => {
|
|
201
|
+
const connection: TestConnection = {
|
|
202
|
+
nodes: [
|
|
203
|
+
{ id: '1', name: '' },
|
|
204
|
+
{ id: '2', name: 'Item 2' },
|
|
205
|
+
{ id: '3', name: '' },
|
|
206
|
+
],
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const selector = (item: { id: string; name: string }) => item.name;
|
|
210
|
+
const renderer = createConnectionRenderer<TestConnection>(
|
|
211
|
+
selector,
|
|
212
|
+
false,
|
|
213
|
+
);
|
|
214
|
+
const result = renderer(connection);
|
|
215
|
+
|
|
216
|
+
expect(result).toBe(', Item 2, ');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should handle single node connection', () => {
|
|
220
|
+
const connection: TestConnection = {
|
|
221
|
+
nodes: [{ id: '1', name: 'Single Item' }],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const selector = (item: { id: string; name: string }) => item.name;
|
|
225
|
+
const renderer = createConnectionRenderer<TestConnection>(
|
|
226
|
+
selector,
|
|
227
|
+
false,
|
|
228
|
+
);
|
|
229
|
+
const result = renderer(connection);
|
|
230
|
+
|
|
231
|
+
expect(result).toBe('Single Item');
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('type safety', () => {
|
|
236
|
+
it('should work with different connection node types', () => {
|
|
237
|
+
interface CustomConnection {
|
|
238
|
+
nodes: { customField: number; label: string }[];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const connection: CustomConnection = {
|
|
242
|
+
nodes: [
|
|
243
|
+
{ customField: 42, label: 'Custom 1' },
|
|
244
|
+
{ customField: 84, label: 'Custom 2' },
|
|
245
|
+
],
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const selector = (item: { customField: number; label: string }) =>
|
|
249
|
+
`${item.label}: ${item.customField}`;
|
|
250
|
+
const renderer = createConnectionRenderer<CustomConnection>(
|
|
251
|
+
selector,
|
|
252
|
+
false,
|
|
253
|
+
);
|
|
254
|
+
const result = renderer(connection);
|
|
255
|
+
|
|
256
|
+
expect(result).toBe('Custom 1: 42, Custom 2: 84');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import { TagsRenderer } from '../../List';
|
|
3
|
+
|
|
1
4
|
interface Connection {
|
|
2
5
|
nodes: unknown[];
|
|
3
6
|
}
|
|
4
7
|
|
|
5
|
-
type SelectorFunction<T> = (
|
|
8
|
+
export type SelectorFunction<T> = (
|
|
9
|
+
value: T,
|
|
10
|
+
index: number,
|
|
11
|
+
array: T[],
|
|
12
|
+
) => unknown;
|
|
6
13
|
|
|
7
14
|
/**
|
|
8
15
|
* Creates a renderer that will loop through each `node` on the value,
|
|
@@ -12,10 +19,18 @@ type SelectorFunction<T> = (value: T, index: number, array: T[]) => unknown;
|
|
|
12
19
|
*/
|
|
13
20
|
export function createConnectionRenderer<T extends Connection>(
|
|
14
21
|
selector: SelectorFunction<T['nodes'][number]>,
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
renderAsTags = true,
|
|
23
|
+
): (val: unknown) => string | ReactNode {
|
|
24
|
+
const ConnectionRenderer = (val: unknown): string | ReactNode => {
|
|
17
25
|
const value = val as T;
|
|
18
26
|
|
|
27
|
+
if (renderAsTags) {
|
|
28
|
+
return TagsRenderer(value.nodes.map(selector));
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
return value.nodes.map(selector).join(', ');
|
|
20
32
|
};
|
|
33
|
+
|
|
34
|
+
ConnectionRenderer.displayName = 'ConnectionRenderer';
|
|
35
|
+
return ConnectionRenderer;
|
|
21
36
|
}
|