@axinom/mosaic-ui 0.64.0-rc.11 → 0.64.0-rc.13

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.64.0-rc.11",
3
+ "version": "0.64.0-rc.13",
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": "309b232320896ccf706d8adb4bd6b38be6499e94"
115
+ "gitHead": "1c5e1d24fa53ea23798a80b5e4d8b13eb2426e42"
116
116
  }
@@ -9,6 +9,7 @@ import { Column, ListElement, ListSelectMode, SortData } from './List.model';
9
9
  import { ListHeader } from './ListHeader/ListHeader';
10
10
  import { ListRow } from './ListRow/ListRow';
11
11
  import { ListRowLoader } from './ListRow/ListRowLoader';
12
+ import { ListRowRenderer } from './ListRowRenderer/ListRowRenderer';
12
13
 
13
14
  /* eslint-disable @typescript-eslint/no-non-null-assertion */
14
15
 
@@ -475,7 +476,10 @@ describe('List', () => {
475
476
  />,
476
477
  );
477
478
 
478
- const onTriggered = wrapper.find(ListRow).first().prop('onTriggered');
479
+ const onTriggered = wrapper
480
+ .find(ListRowRenderer)
481
+ .first()
482
+ .prop('onTriggered');
479
483
  onTriggered!();
480
484
 
481
485
  expect(spy).toHaveBeenCalledTimes(1);
@@ -605,4 +609,208 @@ describe('List', () => {
605
609
 
606
610
  expect(listRow.props().actionSize).toBe(mockSize);
607
611
  });
612
+
613
+ describe('isRowDisabled', () => {
614
+ const mountListWithDisabledRows = (additionalProps = {}) => {
615
+ return mount(
616
+ <List
617
+ columns={mockListColumns}
618
+ data={mockListData}
619
+ {...additionalProps}
620
+ />,
621
+ );
622
+ };
623
+
624
+ const expectRowDisabledStates = (
625
+ wrapper: any,
626
+ expectedStates: boolean[],
627
+ ) => {
628
+ const rows = wrapper.find(ListRow);
629
+ expectedStates.forEach((expected, index) => {
630
+ expect(rows.at(index).prop('isRowDisabled')).toBe(expected);
631
+ });
632
+ };
633
+
634
+ const triggerSelectAll = (wrapper: any) => {
635
+ const headerRow = wrapper.find(ListHeader);
636
+ act(() => {
637
+ headerRow.prop('onCheckboxToggled')!(true);
638
+ });
639
+ wrapper.update();
640
+ };
641
+
642
+ it('should disable rows based on custom isRowDisabled function', () => {
643
+ const isRowDisabledMock = jest.fn(
644
+ (data: ListTestData, _index: number) => data.id === '2',
645
+ );
646
+
647
+ const wrapper = mountListWithDisabledRows({
648
+ isRowDisabled: isRowDisabledMock,
649
+ });
650
+
651
+ // Verify disabled states
652
+ expectRowDisabledStates(wrapper, [false, true, false]);
653
+
654
+ // Verify function calls
655
+ expect(isRowDisabledMock).toHaveBeenCalledTimes(3);
656
+ mockListData.forEach((data, index) => {
657
+ expect(isRowDisabledMock).toHaveBeenNthCalledWith(
658
+ index + 1,
659
+ data,
660
+ index,
661
+ );
662
+ });
663
+ });
664
+
665
+ describe('selectAll behavior', () => {
666
+ const selectAllProps = {
667
+ selectionMode: ListSelectMode.Multi,
668
+ enableSelectAll: true,
669
+ };
670
+
671
+ it('should disable rows when selectAll is enabled and enableSelectAllDeselect is false', () => {
672
+ const wrapper = mountListWithDisabledRows({
673
+ ...selectAllProps,
674
+ enableSelectAllDeselect: false,
675
+ });
676
+
677
+ // Initially all rows should be enabled
678
+ expectRowDisabledStates(wrapper, [false, false, false]);
679
+
680
+ triggerSelectAll(wrapper);
681
+
682
+ // After selectAll, all rows should be disabled
683
+ expectRowDisabledStates(wrapper, [true, true, true]);
684
+
685
+ expect(wrapper.find(ListRow)).toHaveLength(mockListData.length);
686
+ });
687
+
688
+ it('should not disable rows when enableSelectAllDeselect is true', () => {
689
+ const wrapper = mountListWithDisabledRows({
690
+ ...selectAllProps,
691
+ enableSelectAllDeselect: true,
692
+ });
693
+
694
+ triggerSelectAll(wrapper);
695
+
696
+ // Rows should remain enabled when deselect is allowed
697
+ expectRowDisabledStates(wrapper, [false, false, false]);
698
+
699
+ expect(wrapper.find(ListRow)).toHaveLength(mockListData.length);
700
+ });
701
+
702
+ it('should combine custom isRowDisabled with selectAll disabled state', () => {
703
+ const isRowDisabledMock = jest.fn(
704
+ (data: ListTestData) => data.id === '1',
705
+ );
706
+
707
+ const wrapper = mountListWithDisabledRows({
708
+ ...selectAllProps,
709
+ enableSelectAllDeselect: false,
710
+ isRowDisabled: isRowDisabledMock,
711
+ });
712
+
713
+ triggerSelectAll(wrapper);
714
+
715
+ // All rows should be disabled (selectAll OR custom logic)
716
+ expectRowDisabledStates(wrapper, [true, true, true]);
717
+
718
+ expect(wrapper.find(ListRow)).toHaveLength(mockListData.length);
719
+ });
720
+ });
721
+
722
+ // Test different scenarios with parameterized tests
723
+ describe.each([
724
+ {
725
+ name: 'even indices disabled',
726
+ disableLogic: (_data: ListTestData, index: number) => index % 2 === 0,
727
+ expected: [true, false, true],
728
+ },
729
+ {
730
+ name: 'specific IDs disabled',
731
+ disableLogic: (data: ListTestData) => ['1', '3'].includes(data.id),
732
+ expected: [true, false, true],
733
+ },
734
+ {
735
+ name: 'title-based disabled',
736
+ disableLogic: (data: ListTestData) => data.title.includes('2'),
737
+ expected: [false, true, false],
738
+ },
739
+ ])('custom logic: $name', ({ disableLogic, expected }) => {
740
+ it(`should disable rows when ${expected.filter(Boolean).length} out of ${
741
+ expected.length
742
+ } match criteria`, () => {
743
+ const isRowDisabledMock = jest.fn(disableLogic);
744
+ const wrapper = mountListWithDisabledRows({
745
+ isRowDisabled: isRowDisabledMock,
746
+ });
747
+
748
+ expectRowDisabledStates(wrapper, expected);
749
+ expect(isRowDisabledMock).toHaveBeenCalledTimes(mockListData.length);
750
+ });
751
+ });
752
+ });
753
+
754
+ describe('customRowRenderer', () => {
755
+ const createSimpleCustomRenderer = (testId = 'custom-row') =>
756
+ jest.fn(() => <div data-test-id={testId}>Custom Row Content</div>);
757
+
758
+ it('should use custom row renderer when provided', () => {
759
+ const customRowRenderer = createSimpleCustomRenderer();
760
+ const wrapper = mount(
761
+ <List
762
+ columns={mockListColumns}
763
+ data={mockListData}
764
+ customRowRenderer={customRowRenderer}
765
+ />,
766
+ );
767
+
768
+ expect(customRowRenderer).toHaveBeenCalledTimes(3);
769
+ expect(wrapper.find('[data-test-id="custom-row"]')).toHaveLength(3);
770
+ expect(wrapper.find(ListRow)).toHaveLength(0); // No default ListRow components
771
+ });
772
+
773
+ it('should fall back to default ListRow when custom renderer returns null', () => {
774
+ const customRowRenderer = jest.fn(() => null);
775
+ const wrapper = mount(
776
+ <List
777
+ columns={mockListColumns}
778
+ data={mockListData}
779
+ customRowRenderer={customRowRenderer}
780
+ />,
781
+ );
782
+
783
+ expect(customRowRenderer).toHaveBeenCalledTimes(3);
784
+ expect(wrapper.find(ListRow)).toHaveLength(3);
785
+ expect(wrapper.find('[data-test-id="custom-row"]')).toHaveLength(0);
786
+ });
787
+
788
+ it('should pass ListRowProps to custom renderer', () => {
789
+ const customRowRenderer = jest.fn(() => (
790
+ <div data-test-id="custom-row">Custom</div>
791
+ ));
792
+
793
+ mount(
794
+ <List
795
+ columns={mockListColumns}
796
+ data={mockListData}
797
+ customRowRenderer={customRowRenderer}
798
+ />,
799
+ );
800
+
801
+ // Verify first call receives ListRowProps structure
802
+ expect(customRowRenderer).toHaveBeenNthCalledWith(
803
+ 1,
804
+ expect.objectContaining({
805
+ data: mockListData[0],
806
+ columns: mockListColumns,
807
+ columnSizes: '1fr 1fr 1fr 50px',
808
+ itemSelected: false,
809
+ isRowDisabled: false,
810
+ onItemClicked: expect.any(Function),
811
+ onItemSelected: expect.any(Function),
812
+ }),
813
+ );
814
+ });
815
+ });
608
816
  });
@@ -11,6 +11,7 @@ import { Message } from '../Message';
11
11
  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
+ import { ListRowProps } from './ListRow/ListRow';
14
15
  import { createStateRenderer } from './ListRow/Renderers';
15
16
 
16
17
  interface ListStoryData {
@@ -88,6 +89,8 @@ const groups = createGroups({
88
89
  'enableSelectAll',
89
90
  'showActionButton',
90
91
  'loadingTriggerOffset',
92
+ 'isRowDisabled',
93
+ 'customRowRenderer',
91
94
  ],
92
95
  Styling: [
93
96
  'horizontalTextAlign',
@@ -133,6 +136,79 @@ const meta: Meta<StoryListType> = {
133
136
  random: (): boolean => (Math.random() > 0.5 ? true : false),
134
137
  },
135
138
  },
139
+ isRowDisabled: {
140
+ ...groups.isRowDisabled,
141
+ description:
142
+ 'Function to determine if a row should be disabled. Choose from predefined options or disable control for custom logic.',
143
+ control: {
144
+ type: 'select',
145
+ },
146
+ options: ['none', 'evenRows', 'oddRows', 'specificIds', 'randomRows'],
147
+ mapping: {
148
+ none: undefined,
149
+ evenRows: (_data: ListStoryData, index: number) => index % 2 === 0,
150
+ oddRows: (_data: ListStoryData, index: number) => index % 2 !== 0,
151
+ specificIds: (data: ListStoryData) => [1, 3, 5].includes(data.id),
152
+ randomRows: () => Math.random() > 0.7,
153
+ },
154
+ },
155
+ customRowRenderer: {
156
+ ...groups.customRowRenderer,
157
+ description:
158
+ 'Custom renderer for rows. Choose from predefined options or disable control for custom logic.',
159
+ control: {
160
+ type: 'select',
161
+ },
162
+ options: ['none', 'customRenderer'],
163
+ mapping: {
164
+ none: undefined,
165
+ customRenderer: (props: ListRowProps<ListStoryData>) => {
166
+ const renderCustom = Math.random() < 0.3;
167
+
168
+ return renderCustom ? (
169
+ <div
170
+ style={{
171
+ display: 'grid',
172
+ gridTemplateColumns: props.columnSizes,
173
+ columnGap: props.columnGap,
174
+ height: '50px',
175
+ placeItems: 'center left',
176
+ paddingLeft: '5px',
177
+ borderBottom: '1px solid #ddd',
178
+ backgroundColor: '#f5f5f5',
179
+ opacity: props.isRowDisabled ? 0.4 : 0.7,
180
+ cursor: props.isRowDisabled ? 'not-allowed' : 'pointer',
181
+ }}
182
+ onClick={() =>
183
+ !props.isRowDisabled &&
184
+ typeof props.onItemClicked === 'function' &&
185
+ props.onItemClicked(props.data)
186
+ }
187
+ >
188
+ <span>{props.data.id}</span>
189
+ <span>{props.data.title}</span>
190
+ <div
191
+ style={{
192
+ display: 'grid',
193
+ width: '100%',
194
+ overflow: 'hidden',
195
+ }}
196
+ >
197
+ <span
198
+ style={{
199
+ overflow: 'hidden',
200
+ textOverflow: 'ellipsis',
201
+ whiteSpace: 'nowrap',
202
+ }}
203
+ >
204
+ {props.data.desc}
205
+ </span>
206
+ </div>
207
+ </div>
208
+ ) : null;
209
+ },
210
+ },
211
+ },
136
212
  },
137
213
  args: {
138
214
  columns: defaultColumns,
@@ -24,8 +24,9 @@ import {
24
24
  } from './List.model';
25
25
  import classes from './List.scss';
26
26
  import { ListHeader } from './ListHeader/ListHeader';
27
- import { ListRow } from './ListRow/ListRow';
27
+ import { ListRowProps } from './ListRow/ListRow';
28
28
  import { ListRowLoader } from './ListRow/ListRowLoader';
29
+ import { ListRowRenderer } from './ListRowRenderer/ListRowRenderer';
29
30
  import { getActionButtonVisibility, isTrigger, useSort } from './helpers';
30
31
  import { useColumnsSize } from './useColumnsSize';
31
32
 
@@ -110,6 +111,10 @@ export interface ListProps<T extends Data> {
110
111
  className?: string;
111
112
  /** Provide inline actions which are available through '...' context menu */
112
113
  inlineMenuActions?: (data: T) => ActionData[];
114
+ /** Function to determine if a specific row should be disabled */
115
+ isRowDisabled?: (data: T, index: number) => boolean;
116
+ /** Custom row renderer - if it returns null, the default ListRow will be used */
117
+ customRowRenderer?: (props: ListRowProps<T>) => ReactElement | null;
113
118
  }
114
119
 
115
120
  const ListRenderer = <T extends Data>(
@@ -145,6 +150,8 @@ const ListRenderer = <T extends Data>(
145
150
  generateItemLink,
146
151
  className = '',
147
152
  inlineMenuActions,
153
+ isRowDisabled,
154
+ customRowRenderer,
148
155
  }: PropsWithChildren<ListProps<T>>,
149
156
  ref?: React.ForwardedRef<ListElement>,
150
157
  ): JSX.Element => {
@@ -290,6 +297,21 @@ const ListRenderer = <T extends Data>(
290
297
  }
291
298
  }, [selectionMode]);
292
299
 
300
+ const getRowDisabledState = useCallback(
301
+ (item: ListItem<T>, index: number): boolean => {
302
+ const isDisabledDueToSelectAll =
303
+ isAllItemsChecked.current &&
304
+ !enableSelectAllDeselect &&
305
+ selectionMode === ListSelectMode.Multi;
306
+
307
+ const isDisabledByCustomLogic =
308
+ isRowDisabled?.(item.data, index) ?? false;
309
+
310
+ return isDisabledDueToSelectAll || isDisabledByCustomLogic;
311
+ },
312
+ [enableSelectAllDeselect, selectionMode, isRowDisabled],
313
+ );
314
+
293
315
  return (
294
316
  <div
295
317
  className={clsx(classes.wrapper, 'list-wrapper', className)}
@@ -319,43 +341,42 @@ const ListRenderer = <T extends Data>(
319
341
  onColumnSizesChanged={setColumnSizes}
320
342
  hasActionColumn={hasActionColumn}
321
343
  />
344
+
322
345
  {/* Rows */}
323
346
  {listItems.map((item: ListItem<T>, index) => (
324
- <ListRow<T>
347
+ <ListRowRenderer<T>
325
348
  key={String(item.data[keyProperty] || index)}
326
- columns={columns}
327
- data={item.data}
328
- itemSelected={item.selected}
349
+ customRowRenderer={customRowRenderer}
329
350
  isTrigger={isTrigger<T>(index, listItems, loadingTriggerOffset)}
330
- isRowDisabled={
331
- isAllItemsChecked.current &&
332
- !enableSelectAllDeselect &&
333
- selectionMode === ListSelectMode.Multi
334
- }
335
- columnSizes={columnSizes}
336
- columnGap={columnGap}
337
- rowHeight={listRowHeight}
338
- actionSize={listRowActionSize}
339
- horizontalTextAlign={horizontalTextAlign}
340
- verticalTextAlign={verticalTextAlign}
341
- textWrap={textWrap}
342
- selectionMode={selectionMode}
343
- showActionButton={
344
- selectionMode === ListSelectMode.None &&
345
- getActionButtonVisibility(item.data, showActionButton)
346
- }
347
- showCheckMark={selectionMode === ListSelectMode.Single}
348
- showItemCheckbox={selectionMode === ListSelectMode.Multi}
349
- onItemClicked={
350
- generateItemLink
351
- ? generateItemLink(item.data)
352
- : (data) => itemClickedHandler(data, index)
353
- }
354
351
  onTriggered={onTriggeredHandler}
355
- onItemSelected={(checked) => itemSelectedHandler(checked, index)}
356
- inlineMenuActions={inlineMenuActions}
352
+ listRowProps={{
353
+ columns,
354
+ data: item.data,
355
+ itemSelected: item.selected,
356
+ isRowDisabled: getRowDisabledState(item, index),
357
+ columnSizes,
358
+ columnGap,
359
+ rowHeight: listRowHeight,
360
+ actionSize: listRowActionSize,
361
+ horizontalTextAlign,
362
+ verticalTextAlign,
363
+ textWrap,
364
+ selectionMode,
365
+ showActionButton:
366
+ selectionMode === ListSelectMode.None &&
367
+ getActionButtonVisibility(item.data, showActionButton),
368
+ showCheckMark: selectionMode === ListSelectMode.Single,
369
+ showItemCheckbox: selectionMode === ListSelectMode.Multi,
370
+ onItemClicked: generateItemLink
371
+ ? generateItemLink(item.data)
372
+ : (data: T) => itemClickedHandler(data, index),
373
+ onItemSelected: (checked: boolean) =>
374
+ itemSelectedHandler(checked, index),
375
+ inlineMenuActions,
376
+ }}
357
377
  />
358
378
  ))}
379
+
359
380
  {isLoading && (
360
381
  <ListRowLoader
361
382
  columnSizes={columnSizes}
@@ -27,6 +27,11 @@
27
27
  }
28
28
  }
29
29
 
30
+ &.disabled {
31
+ cursor: not-allowed;
32
+ color: var(--disabled-text-color, $gray);
33
+ }
34
+
30
35
  .cellWrapper {
31
36
  display: grid;
32
37
  width: 100%;