@axinom/mosaic-ui 0.65.0-rc.1 → 0.65.0-rc.10

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.
Files changed (32) hide show
  1. package/dist/components/Explorer/BulkEdit/useBulkEdit.d.ts.map +1 -1
  2. package/dist/components/Filters/Filter/Filter.d.ts +10 -0
  3. package/dist/components/Filters/Filter/Filter.d.ts.map +1 -1
  4. package/dist/components/Filters/Filters.model.d.ts +9 -1
  5. package/dist/components/Filters/Filters.model.d.ts.map +1 -1
  6. package/dist/components/Filters/SelectionTypes/SearcheableOptionsFilter/helpers.d.ts +4 -0
  7. package/dist/components/Filters/SelectionTypes/SearcheableOptionsFilter/helpers.d.ts.map +1 -0
  8. package/dist/components/List/ListRow/Renderers/TagsRenderer/TagsRenderer.d.ts +3 -0
  9. package/dist/components/List/ListRow/Renderers/TagsRenderer/TagsRenderer.d.ts.map +1 -0
  10. package/dist/components/List/ListRow/Renderers/index.d.ts +1 -0
  11. package/dist/components/List/ListRow/Renderers/index.d.ts.map +1 -1
  12. package/dist/components/Loaders/ImageLoader/ImageLoader.d.ts.map +1 -1
  13. package/dist/components/Utils/Postgraphile/CreateConnectionRenderer.d.ts +3 -2
  14. package/dist/components/Utils/Postgraphile/CreateConnectionRenderer.d.ts.map +1 -1
  15. package/dist/index.es.js +4 -4
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/index.js +4 -4
  18. package/dist/index.js.map +1 -1
  19. package/package.json +2 -2
  20. package/src/components/Explorer/BulkEdit/useBulkEdit.tsx +0 -1
  21. package/src/components/Filters/Filter/Filter.tsx +17 -1
  22. package/src/components/Filters/Filters.model.ts +9 -1
  23. package/src/components/Filters/SelectionTypes/SearcheableOptionsFilter/helpers.ts +24 -0
  24. package/src/components/List/List.stories.tsx +19 -1
  25. package/src/components/List/ListRow/Renderers/TagsRenderer/TagsRenderer.scss +25 -0
  26. package/src/components/List/ListRow/Renderers/TagsRenderer/TagsRenderer.spec.tsx +153 -0
  27. package/src/components/List/ListRow/Renderers/TagsRenderer/TagsRenderer.tsx +133 -0
  28. package/src/components/List/ListRow/Renderers/index.ts +1 -0
  29. package/src/components/Loaders/ImageLoader/ImageLoader.tsx +4 -1
  30. package/src/components/Utils/Postgraphile/CreateConnectionRenderer.spec.ts +259 -0
  31. package/src/components/Utils/Postgraphile/{CreateConnectionRenderer.ts → CreateConnectionRenderer.tsx} +18 -3
  32. 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.1",
3
+ "version": "0.65.0-rc.10",
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": "e0d14e838bd4adb3476dd12bfaab15588d8dfa1e"
115
+ "gitHead": "1db91b67fe80347707fd3960cea16505ea3aedcf"
116
116
  }
@@ -75,7 +75,6 @@ export const useBulkEdit = <T extends Data>({
75
75
  ? {
76
76
  label: bulkEditRegistration.label ?? 'Edit',
77
77
  icon: bulkEditRegistration.icon ?? IconName.BulkEdit,
78
- tag: 'BETA',
79
78
  onClick: () => setIsBulkEditMode((prev) => !prev),
80
79
  }
81
80
  : undefined,
@@ -18,18 +18,29 @@ import { MultiOptionsFilter } from '../SelectionTypes/MultiOptionFilter/MultiOpt
18
18
  import { NumericTextFilter } from '../SelectionTypes/NumericTextFilter/NumericTextFilter';
19
19
  import { OptionsFilter } from '../SelectionTypes/OptionsFilter/OptionsFilter';
20
20
  import { SearcheableOptionsFilter } from '../SelectionTypes/SearcheableOptionsFilter/SearcheableOptionsFilter';
21
+ import { renderSearcheableOptionsFilterValue } from '../SelectionTypes/SearcheableOptionsFilter/helpers';
21
22
  import classes from './Filter.scss';
22
23
 
23
24
  export interface FilterProps<T extends Data> {
25
+ /** Focus on the element when the filter is opened. (default: true) */
24
26
  selectOnFocus?: boolean;
27
+ /** Filter options */
25
28
  options: FilterType<T>;
29
+ /** Current value of the filter */
26
30
  value?: FilterValue;
31
+ /** Index of the filter in the list of filters */
27
32
  index?: number;
33
+ /** Wether or not the filter is active. */
28
34
  isActive: boolean;
35
+ /** Additional classNames to add to the filter container */
29
36
  className?: string;
37
+ /** Custom renderer for the selected value */
30
38
  selectedValueRenderer?: (value: FilterValue) => string;
39
+ /** Callback triggered when a new filter value is selected */
31
40
  onFilterChange: (prop: keyof T, value: FilterValue, index: number) => void;
41
+ /** Callback triggered for custom validations of the filter */
32
42
  onValidate?: (currentValue: FilterValue) => string | null | undefined;
43
+ /** Callback triggered when the filter is clicked */
33
44
  onFilterClicked: () => void;
34
45
  }
35
46
 
@@ -101,7 +112,12 @@ export const Filter = <T extends Data>({
101
112
  return selectedVal.join(', ');
102
113
  }
103
114
  case FilterTypes.SearcheableOptions:
104
- return stringValue;
115
+ return renderSearcheableOptionsFilterValue(
116
+ value,
117
+ stringValue,
118
+ setStringValue,
119
+ options,
120
+ );
105
121
  default:
106
122
  return stringValue ?? String(value);
107
123
  }
@@ -12,10 +12,15 @@ export enum FilterTypes {
12
12
  }
13
13
 
14
14
  export interface FilterConfig<T extends Data> {
15
+ /** Label displayed for the filter */
15
16
  label: string;
17
+ /** Property of the data the filter applies to */
16
18
  property: keyof T;
19
+ /** Type of the filter */
17
20
  type: FilterTypes;
21
+ /** Custom renderer for the selected value */
18
22
  selectedValueRenderer?: SelectedValueRenderer;
23
+ /** Callback triggered when the filter is validated */
19
24
  onValidate?: FilterValidatorFunction<T>;
20
25
  }
21
26
 
@@ -45,8 +50,11 @@ export interface MultipleOptions<T extends Data> extends FilterConfig<T> {
45
50
  export interface SearcheableOptionsFilter<T extends Data>
46
51
  extends FilterConfig<T> {
47
52
  type: FilterTypes.SearcheableOptions;
48
- optionsProvider: (searchText: string) => Option[];
53
+ /** Callback triggered when the search input changes. This is also used to resolve the selected option from the value. */
54
+ optionsProvider: (searchText: string, value?: unknown) => Option[];
55
+ /** Placeholder for the search input */
49
56
  searchInputPlaceholder?: string;
57
+ /** Maximum number of items to display */
50
58
  maxItems?: number;
51
59
  }
52
60
 
@@ -0,0 +1,24 @@
1
+ import { Data } from '../../../../types';
2
+ import { FilterValue, SearcheableOptionsFilter } from '../../Filters.model';
3
+
4
+ export function renderSearcheableOptionsFilterValue<T extends Data>(
5
+ value: FilterValue,
6
+ stringValue: string | undefined,
7
+ setStringValue: (val: string) => void,
8
+ options: SearcheableOptionsFilter<T>,
9
+ ): string {
10
+ if (stringValue !== undefined) {
11
+ return stringValue;
12
+ }
13
+
14
+ const selectedVal = options
15
+ .optionsProvider('', value)
16
+ .find((option) => option.value === value)?.label;
17
+
18
+ if (selectedVal !== undefined) {
19
+ setStringValue(selectedVal);
20
+ return selectedVal;
21
+ }
22
+
23
+ return String(value);
24
+ }
@@ -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,153 @@
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
+ const { container } = render(
132
+ <RendererWrapper {...defaultProps} value={[]} />,
133
+ );
134
+ expect(container.firstChild).toBeNull();
135
+ });
136
+
137
+ it('cleans up ResizeObserver on unmount', () => {
138
+ const mockDisconnect = jest.fn();
139
+ (global.ResizeObserver as jest.Mock).mockImplementation(() => ({
140
+ observe: jest.fn(),
141
+ unobserve: jest.fn(),
142
+ disconnect: mockDisconnect,
143
+ }));
144
+
145
+ const { unmount } = render(
146
+ <RendererWrapper {...defaultProps} value={['tag1', 'tag2']} />,
147
+ );
148
+
149
+ unmount();
150
+
151
+ expect(mockDisconnect).toHaveBeenCalled();
152
+ });
153
+ });
@@ -0,0 +1,133 @@
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 || val.length === 0) {
12
+ return;
13
+ }
14
+
15
+ const calculateVisibleItems = (): void => {
16
+ const container = containerRef.current;
17
+ if (!container || tagRefs.current.length === 0) {
18
+ return;
19
+ }
20
+
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 refs
26
+ const tagWidths: number[] = [];
27
+ tagRefs.current.forEach((tagRef) => {
28
+ if (tagRef) {
29
+ tagWidths.push(tagRef.offsetWidth);
30
+ }
31
+ });
32
+
33
+ // Calculate how many items can fit
34
+ let totalWidth = 0;
35
+ let visibleCount = 0;
36
+
37
+ for (let i = 0; i < val.length; i++) {
38
+ const itemWidth = tagWidths[i] + (i > 0 ? gap : 0);
39
+ const wouldNeedOverflow = i < val.length - 1;
40
+ const requiredWidth =
41
+ totalWidth +
42
+ itemWidth +
43
+ (wouldNeedOverflow ? overflowIndicatorWidth + gap : 0);
44
+
45
+ if (requiredWidth <= containerWidth) {
46
+ totalWidth += itemWidth;
47
+ visibleCount++;
48
+ } else {
49
+ break;
50
+ }
51
+ }
52
+
53
+ // If we can't fit all items, reserve space for overflow indicator
54
+ if (visibleCount < val.length && visibleCount > 0) {
55
+ // Re-calculate with overflow indicator space reserved
56
+ totalWidth = 0;
57
+ visibleCount = 0;
58
+
59
+ for (let i = 0; i < val.length; i++) {
60
+ const itemWidth = tagWidths[i] + (i > 0 ? gap : 0);
61
+ const requiredWidth =
62
+ totalWidth + itemWidth + overflowIndicatorWidth + gap;
63
+
64
+ if (requiredWidth <= containerWidth && i < val.length - 1) {
65
+ totalWidth += itemWidth;
66
+ visibleCount++;
67
+ } else {
68
+ break;
69
+ }
70
+ }
71
+ }
72
+
73
+ setVisibleItems(val.slice(0, visibleCount));
74
+ setHiddenItems(val.slice(visibleCount));
75
+ };
76
+
77
+ // Initial calculation after render
78
+ const timer = setTimeout(() => {
79
+ calculateVisibleItems();
80
+ }, 0);
81
+
82
+ const resizeObserver = new ResizeObserver(calculateVisibleItems);
83
+ resizeObserver.observe(containerRef.current);
84
+
85
+ return () => {
86
+ clearTimeout(timer);
87
+ resizeObserver.disconnect();
88
+ };
89
+ }, [val]);
90
+
91
+ if (!Array.isArray(val) || val.length === 0) {
92
+ return <></>;
93
+ }
94
+
95
+ const stringArray = val as string[];
96
+
97
+ // Create tooltip text for hidden items
98
+ const tooltipText = hiddenItems.length > 0 ? hiddenItems.join(', ') : '';
99
+
100
+ return (
101
+ <div
102
+ ref={containerRef}
103
+ className={classes.container}
104
+ data-testid="tags-container"
105
+ title={stringArray.join(', ')}
106
+ >
107
+ {stringArray.map((item, index) => {
108
+ const isVisible = visibleItems.includes(item);
109
+
110
+ return (
111
+ <div
112
+ key={`${item}-${index}`}
113
+ ref={(el) => {
114
+ tagRefs.current[index] = el;
115
+ }}
116
+ className={classes.tag}
117
+ style={{
118
+ visibility: isVisible ? 'visible' : 'hidden',
119
+ position: isVisible ? 'static' : 'absolute',
120
+ }}
121
+ >
122
+ {item}
123
+ </div>
124
+ );
125
+ })}
126
+ {hiddenItems.length > 0 && (
127
+ <div className={classes.overflowIndicator} title={tooltipText}>
128
+ {`+${hiddenItems.length}`}
129
+ </div>
130
+ )}
131
+ </div>
132
+ );
133
+ };
@@ -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';
@@ -87,8 +87,11 @@ export const ImageLoader: React.FC<ImageLoaderProps> = ({
87
87
  useEffect(() => {
88
88
  if (imgSrc === undefined) {
89
89
  setState(ImageLoaderState.Failed);
90
+ } else if (state !== ImageLoaderState.Loading) {
91
+ setState(ImageLoaderState.Loading);
90
92
  }
91
- setState(ImageLoaderState.Loading);
93
+ // else: already loading, don't reset
94
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
95
  }, [imgSrc]);
93
96
 
94
97
  // Fallback if the image source fails to load in time