@axinom/mosaic-ui 0.65.3 → 0.65.5

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@axinom/mosaic-ui",
3
- "version": "0.65.3",
3
+ "version": "0.65.5",
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": "cac34831ec1092ebcbe246bb09f610a64edc81a6"
115
+ "gitHead": "2dccfe1c1492cbb54be6b6e9af3602e717cdce57"
116
116
  }
@@ -21,6 +21,7 @@ import {
21
21
  } from '../FormStation';
22
22
  import { IconName } from '../Icons';
23
23
  import { ListSelectMode } from '../List';
24
+ import { createConnectionRenderer } from '../Utils';
24
25
  import { generateBulkEditMutation } from './BulkEdit/GenerateMutation';
25
26
  import { Explorer } from './Explorer';
26
27
  import { QuickEditContext } from './QuickEdit/QuickEditContext';
@@ -34,6 +35,7 @@ interface ExplorerStoryData {
34
35
  title: string;
35
36
  date?: Date;
36
37
  desc: string;
38
+ tags?: { nodes: { name: string }[] };
37
39
  }
38
40
 
39
41
  type ExplorerStoryType = typeof Explorer<ExplorerStoryData>;
@@ -120,6 +122,14 @@ const generateData = (
120
122
  title: `${usePrefix ? `Index ${index}: ` : ''}${faker.random.words(
121
123
  faker.datatype.number({ min: 1, max: 3 }),
122
124
  )}`,
125
+ tags: {
126
+ nodes: Array.from(
127
+ { length: faker.datatype.number({ min: 0, max: 10 }) },
128
+ () => ({
129
+ name: faker.random.word(),
130
+ }),
131
+ ),
132
+ },
123
133
  };
124
134
  });
125
135
 
@@ -140,6 +150,13 @@ export const Default: StoryObj<ExplorerStoryType> = {
140
150
  propertyName: 'title',
141
151
  label: 'Title',
142
152
  },
153
+ {
154
+ propertyName: 'tags',
155
+ label: 'Tags',
156
+ render: createConnectionRenderer<{ nodes: { name: string }[] }>(
157
+ (tag) => tag.name,
158
+ ),
159
+ },
143
160
  {
144
161
  propertyName: 'date',
145
162
  label: 'Date',
@@ -34,27 +34,6 @@ describe('TagsRenderer', () => {
34
34
  expect(container.firstChild).toBeInTheDocument();
35
35
  });
36
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
37
  it('renders tags when array is provided', () => {
59
38
  const mockTags = ['tag1', 'tag2', 'tag3'];
60
39
 
@@ -127,13 +106,6 @@ describe('TagsRenderer', () => {
127
106
  expect(container).toBeInTheDocument();
128
107
  });
129
108
 
130
- it('handles empty array', () => {
131
- const { container } = render(
132
- <RendererWrapper {...defaultProps} value={[]} />,
133
- );
134
- expect(container.firstChild).toBeNull();
135
- });
136
-
137
109
  it('cleans up ResizeObserver on unmount', () => {
138
110
  const mockDisconnect = jest.fn();
139
111
  (global.ResizeObserver as jest.Mock).mockImplementation(() => ({
@@ -6,91 +6,120 @@ export const TagsRenderer = (val: unknown): JSX.Element => {
6
6
  const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
7
7
  const [visibleCount, setVisibleCount] = useState<number>(0);
8
8
 
9
- useEffect(() => {
10
- if (!Array.isArray(val) || !containerRef.current || val.length === 0) {
11
- setVisibleCount(0);
9
+ const prevValRef = useRef<string[]>([]);
10
+ const valRef = useRef(val);
11
+ valRef.current = val; // Update on every render
12
+
13
+ // Shared calculation function that uses valRef for current value
14
+ const calculateVisibleItems = (): void => {
15
+ const currentVal = valRef.current;
16
+ const container = containerRef.current;
17
+
18
+ if (!container || !Array.isArray(currentVal) || currentVal.length === 0) {
12
19
  return;
13
20
  }
14
21
 
15
- const calculateVisibleItems = (): void => {
16
- const container = containerRef.current;
17
- if (!container) {
18
- return;
19
- }
22
+ const containerWidth = container.offsetWidth;
23
+ const gap = 5; // Gap between items from SCSS
24
+ const overflowIndicatorWidth = 60; // Approximate width for "... +X"
25
+
26
+ // Measure actual tag widths using hidden temporary elements in the actual container
27
+ const tagWidths: number[] = [];
28
+ const tempElements: HTMLDivElement[] = [];
29
+
30
+ try {
31
+ (currentVal as string[]).forEach((item) => {
32
+ const tempTag = document.createElement('div');
33
+ tempTag.className = classes.tag;
34
+ tempTag.textContent = item;
35
+ tempTag.style.position = 'absolute';
36
+ tempTag.style.visibility = 'hidden';
37
+ tempTag.style.pointerEvents = 'none';
38
+ container.appendChild(tempTag);
39
+ tempElements.push(tempTag);
40
+ tagWidths.push(tempTag.offsetWidth);
41
+ });
42
+ } finally {
43
+ // Clean up temporary elements
44
+ tempElements.forEach((el) => container.removeChild(el));
45
+ }
20
46
 
21
- const containerWidth = container.offsetWidth;
22
- const gap = 5; // Gap between items from SCSS
23
- const overflowIndicatorWidth = 60; // Approximate width for "... +X"
24
-
25
- // Measure actual tag widths using hidden temporary elements in the actual container
26
- const tagWidths: number[] = [];
27
- const tempElements: HTMLDivElement[] = [];
28
-
29
- try {
30
- (val as string[]).forEach((item) => {
31
- const tempTag = document.createElement('div');
32
- tempTag.className = classes.tag;
33
- tempTag.textContent = item;
34
- tempTag.style.position = 'absolute';
35
- tempTag.style.visibility = 'hidden';
36
- tempTag.style.pointerEvents = 'none';
37
- container.appendChild(tempTag);
38
- tempElements.push(tempTag);
39
- tagWidths.push(tempTag.offsetWidth);
40
- });
41
- } finally {
42
- // Clean up temporary elements
43
- tempElements.forEach((el) => container.removeChild(el));
47
+ // Calculate how many items can fit
48
+ let totalWidth = 0;
49
+ let newVisibleCount = 0;
50
+
51
+ for (let i = 0; i < (currentVal as string[]).length; i++) {
52
+ const itemWidth = tagWidths[i] + (i > 0 ? gap : 0);
53
+ const wouldNeedOverflow = i < (currentVal as string[]).length - 1;
54
+ const requiredWidth =
55
+ totalWidth +
56
+ itemWidth +
57
+ (wouldNeedOverflow ? overflowIndicatorWidth + gap : 0);
58
+
59
+ if (requiredWidth <= containerWidth) {
60
+ totalWidth += itemWidth;
61
+ newVisibleCount++;
62
+ } else {
63
+ break;
44
64
  }
65
+ }
45
66
 
46
- // Calculate how many items can fit
47
- let totalWidth = 0;
48
- let newVisibleCount = 0;
67
+ // If we can't fit all items, reserve space for overflow indicator
68
+ if (
69
+ newVisibleCount < (currentVal as string[]).length &&
70
+ newVisibleCount > 0
71
+ ) {
72
+ // Re-calculate with overflow indicator space reserved
73
+ totalWidth = 0;
74
+ newVisibleCount = 0;
49
75
 
50
- for (let i = 0; i < val.length; i++) {
76
+ for (let i = 0; i < (currentVal as string[]).length; i++) {
51
77
  const itemWidth = tagWidths[i] + (i > 0 ? gap : 0);
52
- const wouldNeedOverflow = i < val.length - 1;
53
78
  const requiredWidth =
54
- totalWidth +
55
- itemWidth +
56
- (wouldNeedOverflow ? overflowIndicatorWidth + gap : 0);
79
+ totalWidth + itemWidth + overflowIndicatorWidth + gap;
57
80
 
58
- if (requiredWidth <= containerWidth) {
81
+ if (
82
+ requiredWidth <= containerWidth &&
83
+ i < (currentVal as string[]).length - 1
84
+ ) {
59
85
  totalWidth += itemWidth;
60
86
  newVisibleCount++;
61
87
  } else {
62
88
  break;
63
89
  }
64
90
  }
91
+ }
65
92
 
66
- // If we can't fit all items, reserve space for overflow indicator
67
- if (newVisibleCount < val.length && newVisibleCount > 0) {
68
- // Re-calculate with overflow indicator space reserved
69
- totalWidth = 0;
70
- newVisibleCount = 0;
71
-
72
- for (let i = 0; i < val.length; i++) {
73
- const itemWidth = tagWidths[i] + (i > 0 ? gap : 0);
74
- const requiredWidth =
75
- totalWidth + itemWidth + overflowIndicatorWidth + gap;
76
-
77
- if (requiredWidth <= containerWidth && i < val.length - 1) {
78
- totalWidth += itemWidth;
79
- newVisibleCount++;
80
- } else {
81
- break;
82
- }
83
- }
84
- }
93
+ // Update state with the new visible count
94
+ setVisibleCount(newVisibleCount);
95
+ };
85
96
 
86
- // Update state with the new visible count
87
- setVisibleCount(newVisibleCount);
88
- };
97
+ // Effect 1: Handle tag changes (runs when val changes)
98
+ useEffect(() => {
99
+ if (!Array.isArray(val) || val.length === 0) {
100
+ setVisibleCount(0);
101
+ prevValRef.current = [];
102
+ return;
103
+ }
104
+
105
+ // Check if array contents actually changed
106
+ const valArray = val as string[];
107
+ const hasChanged =
108
+ valArray.length !== prevValRef.current.length ||
109
+ valArray.some((item, index) => item !== prevValRef.current[index]);
110
+
111
+ if (hasChanged) {
112
+ calculateVisibleItems();
113
+ prevValRef.current = [...valArray];
114
+ }
115
+ }, [val]);
89
116
 
90
- // Initial calculation
91
- calculateVisibleItems();
117
+ // Effect 2: Set up ResizeObserver (runs once on mount)
118
+ useEffect(() => {
119
+ if (!containerRef.current) {
120
+ return;
121
+ }
92
122
 
93
- // Debounced resize handler
94
123
  const debouncedCalculate = (): void => {
95
124
  if (resizeTimeoutRef.current) {
96
125
  clearTimeout(resizeTimeoutRef.current);
@@ -109,13 +138,9 @@ export const TagsRenderer = (val: unknown): JSX.Element => {
109
138
  }
110
139
  resizeObserver.disconnect();
111
140
  };
112
- }, [val]);
113
-
114
- if (!Array.isArray(val) || val.length === 0) {
115
- return <></>;
116
- }
141
+ }, []); // Empty deps - only runs once
117
142
 
118
- const stringArray = val as string[];
143
+ const stringArray = Array.isArray(val) ? (val as string[]) : [];
119
144
  const visibleItems = stringArray.slice(0, visibleCount);
120
145
  const hiddenItems = stringArray.slice(visibleCount);
121
146
 
@@ -1,14 +1,5 @@
1
- import { TagsRenderer } from '../../List';
2
1
  import { createConnectionRenderer } from './CreateConnectionRenderer';
3
2
 
4
- jest.mock('../../List', () => ({
5
- TagsRenderer: jest.fn(),
6
- }));
7
-
8
- const mockTagsRenderer = TagsRenderer as jest.MockedFunction<
9
- typeof TagsRenderer
10
- >;
11
-
12
3
  interface TestConnection {
13
4
  nodes: { id: string; name: string }[];
14
5
  }
@@ -18,70 +9,7 @@ interface StringConnection {
18
9
  }
19
10
 
20
11
  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', () => {
12
+ describe('basic functionality', () => {
85
13
  it('should return comma-separated string using selector', () => {
86
14
  const connection: TestConnection = {
87
15
  nodes: [
@@ -92,13 +20,9 @@ describe('createConnectionRenderer', () => {
92
20
  };
93
21
 
94
22
  const selector = (item: { id: string; name: string }) => item.name;
95
- const renderer = createConnectionRenderer<TestConnection>(
96
- selector,
97
- false,
98
- );
23
+ const renderer = createConnectionRenderer<TestConnection>(selector);
99
24
  const result = renderer(connection);
100
25
 
101
- expect(mockTagsRenderer).not.toHaveBeenCalled();
102
26
  expect(result).toBe('Item 1, Item 2, Item 3');
103
27
  });
104
28
 
@@ -108,10 +32,7 @@ describe('createConnectionRenderer', () => {
108
32
  };
109
33
 
110
34
  const selector = (item: string) => item;
111
- const renderer = createConnectionRenderer<StringConnection>(
112
- selector,
113
- false,
114
- );
35
+ const renderer = createConnectionRenderer<StringConnection>(selector);
115
36
  const result = renderer(connection);
116
37
 
117
38
  expect(result).toBe('apple, banana, cherry');
@@ -127,10 +48,7 @@ describe('createConnectionRenderer', () => {
127
48
 
128
49
  const selector = (item: { id: string; name: string }, index: number) =>
129
50
  `${index + 1}. ${item.name} (${item.id})`;
130
- const renderer = createConnectionRenderer<TestConnection>(
131
- selector,
132
- false,
133
- );
51
+ const renderer = createConnectionRenderer<TestConnection>(selector);
134
52
  const result = renderer(connection);
135
53
 
136
54
  expect(result).toBe('1. Item 1 (1), 2. Item 2 (2)');
@@ -139,10 +57,7 @@ describe('createConnectionRenderer', () => {
139
57
  it('should return empty string when nodes array is empty', () => {
140
58
  const connection: TestConnection = { nodes: [] };
141
59
  const selector = (item: { id: string; name: string }) => item.name;
142
- const renderer = createConnectionRenderer<TestConnection>(
143
- selector,
144
- false,
145
- );
60
+ const renderer = createConnectionRenderer<TestConnection>(selector);
146
61
  const result = renderer(connection);
147
62
 
148
63
  expect(result).toBe('');
@@ -160,25 +75,33 @@ describe('createConnectionRenderer', () => {
160
75
  };
161
76
 
162
77
  const selector = (item: { id: number; value: number }) => item.value;
163
- const renderer = createConnectionRenderer<NumberConnection>(
164
- selector,
165
- false,
166
- );
78
+ const renderer = createConnectionRenderer<NumberConnection>(selector);
167
79
  const result = renderer(connection);
168
80
 
169
81
  expect(result).toBe('100, 200');
170
82
  });
171
83
 
172
- it('should throw error when connection.nodes is undefined and renderAsTags is false', () => {
84
+ it('should throw error when connection.nodes is undefined', () => {
173
85
  const connection = { nodes: undefined } as unknown as TestConnection;
174
86
  const selector = (item: { id: string; name: string }) => item.name;
175
- const renderer = createConnectionRenderer<TestConnection>(
176
- selector,
177
- false,
178
- );
87
+ const renderer = createConnectionRenderer<TestConnection>(selector);
179
88
 
180
89
  expect(() => renderer(connection)).toThrow();
181
90
  });
91
+
92
+ it('should throw error when value is null', () => {
93
+ const selector = (item: { id: string; name: string }) => item.name;
94
+ const renderer = createConnectionRenderer<TestConnection>(selector);
95
+
96
+ expect(() => renderer(null)).toThrow();
97
+ });
98
+
99
+ it('should throw error when value is undefined', () => {
100
+ const selector = (item: { id: string; name: string }) => item.name;
101
+ const renderer = createConnectionRenderer<TestConnection>(selector);
102
+
103
+ expect(() => renderer(undefined)).toThrow();
104
+ });
182
105
  });
183
106
 
184
107
  describe('edge cases', () => {
@@ -188,10 +111,7 @@ describe('createConnectionRenderer', () => {
188
111
  };
189
112
 
190
113
  const selector = (item: string) => item;
191
- const renderer = createConnectionRenderer<StringConnection>(
192
- selector,
193
- false,
194
- );
114
+ const renderer = createConnectionRenderer<StringConnection>(selector);
195
115
  const result = renderer(connection);
196
116
 
197
117
  expect(result).toBe('item, with comma, item with "quotes", normal item');
@@ -207,10 +127,7 @@ describe('createConnectionRenderer', () => {
207
127
  };
208
128
 
209
129
  const selector = (item: { id: string; name: string }) => item.name;
210
- const renderer = createConnectionRenderer<TestConnection>(
211
- selector,
212
- false,
213
- );
130
+ const renderer = createConnectionRenderer<TestConnection>(selector);
214
131
  const result = renderer(connection);
215
132
 
216
133
  expect(result).toBe(', Item 2, ');
@@ -222,10 +139,7 @@ describe('createConnectionRenderer', () => {
222
139
  };
223
140
 
224
141
  const selector = (item: { id: string; name: string }) => item.name;
225
- const renderer = createConnectionRenderer<TestConnection>(
226
- selector,
227
- false,
228
- );
142
+ const renderer = createConnectionRenderer<TestConnection>(selector);
229
143
  const result = renderer(connection);
230
144
 
231
145
  expect(result).toBe('Single Item');
@@ -247,10 +161,7 @@ describe('createConnectionRenderer', () => {
247
161
 
248
162
  const selector = (item: { customField: number; label: string }) =>
249
163
  `${item.label}: ${item.customField}`;
250
- const renderer = createConnectionRenderer<CustomConnection>(
251
- selector,
252
- false,
253
- );
164
+ const renderer = createConnectionRenderer<CustomConnection>(selector);
254
165
  const result = renderer(connection);
255
166
 
256
167
  expect(result).toBe('Custom 1: 42, Custom 2: 84');
@@ -1,5 +1,4 @@
1
1
  import { ReactNode } from 'react';
2
- import { TagsRenderer } from '../../List';
3
2
 
4
3
  interface Connection {
5
4
  nodes: unknown[];
@@ -19,14 +18,14 @@ export type SelectorFunction<T> = (
19
18
  */
20
19
  export function createConnectionRenderer<T extends Connection>(
21
20
  selector: SelectorFunction<T['nodes'][number]>,
22
- renderAsTags = true,
21
+ // renderAsTags = true,
23
22
  ): (val: unknown) => string | ReactNode {
24
23
  const ConnectionRenderer = (val: unknown): string | ReactNode => {
25
24
  const value = val as T;
26
25
 
27
- if (renderAsTags) {
28
- return TagsRenderer(value.nodes.map(selector));
29
- }
26
+ // if (renderAsTags) {
27
+ // return TagsRenderer(value.nodes.map(selector));
28
+ // }
30
29
 
31
30
  return value.nodes.map(selector).join(', ');
32
31
  };