@centreon/ui 26.11.8 → 26.11.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@centreon/ui",
3
- "version": "26.11.8",
3
+ "version": "26.11.10",
4
4
  "description": "Centreon UI Components",
5
5
  "scripts": {
6
6
  "update:deps": "pnpx npm-check-updates -i --format group",
@@ -56,7 +56,7 @@
56
56
  "test/*"
57
57
  ],
58
58
  "devDependencies": {
59
- "@biomejs/biome": "2.3.11",
59
+ "@biomejs/biome": "2.5.14",
60
60
  "@cypress/react": "^9.0.0",
61
61
  "@cypress/webpack-dev-server": "^4.0.1",
62
62
  "@faker-js/faker": "^9.5.1",
@@ -57,7 +57,6 @@ const ConnectedAutocomplete = ({
57
57
  ? [
58
58
  {
59
59
  name: 'name[lk]',
60
- // The field wraps the typed text in `%`.
61
60
  value: searchedValue.slice(1, -1)
62
61
  }
63
62
  ]
@@ -79,4 +79,4 @@ const Annotation = ({
79
79
  };
80
80
 
81
81
  export default Annotation;
82
- export { yMargin, iconSize };
82
+ export { iconSize, yMargin };
@@ -42,11 +42,11 @@ const getCurveFactory = (
42
42
 
43
43
  export {
44
44
  commonTickLabelProps,
45
- margin,
46
- getFillColor,
47
45
  dateFormat,
48
- timeFormat,
49
46
  dateTimeFormat,
47
+ getCurveFactory,
48
+ getFillColor,
49
+ margin,
50
50
  maxLinesDisplayedLegend,
51
- getCurveFactory
51
+ timeFormat
52
52
  };
@@ -5,7 +5,7 @@ import { useMemoComponent } from '@centreon/ui';
5
5
  import type { ReactElement } from 'react';
6
6
 
7
7
  import type { LineChartHeader } from './models';
8
- import { ussHeaderChartStyles } from './useHeaderStyles';
8
+ import { useHeaderChartStyles } from './useHeaderStyles';
9
9
 
10
10
  interface Props {
11
11
  header?: LineChartHeader;
@@ -13,7 +13,7 @@ interface Props {
13
13
  }
14
14
 
15
15
  const Header = ({ title, header }: Props): ReactElement => {
16
- const { classes } = ussHeaderChartStyles();
16
+ const { classes } = useHeaderChartStyles();
17
17
 
18
18
  const displayTitle = header?.displayTitle ?? true;
19
19
 
@@ -1,6 +1,6 @@
1
1
  import { makeStyles } from 'tss-react/mui';
2
2
 
3
- export const ussHeaderChartStyles = makeStyles()({
3
+ export const useHeaderChartStyles = makeStyles()({
4
4
  header: {
5
5
  display: 'grid',
6
6
  gridTemplateColumns: 'auto 1fr auto',
@@ -397,11 +397,14 @@ describe('timeSeries', () => {
397
397
  [null, 'B', 1024, null]
398
398
  ];
399
399
 
400
- it.each(
401
- cases
402
- )('formats the given value to a human readable form according to the given unit and base', (value, unit, base, formattedResult) => {
403
- expect(formatMetricValue({ base, unit, value })).toEqual(formattedResult);
404
- });
400
+ it.each(cases)(
401
+ 'formats the given value to a human readable form according to the given unit and base',
402
+ (value, unit, base, formattedResult) => {
403
+ expect(formatMetricValue({ base, unit, value })).toEqual(
404
+ formattedResult
405
+ );
406
+ }
407
+ );
405
408
  });
406
409
 
407
410
  describe('getSortedStackedLines', () => {
@@ -639,33 +642,31 @@ describe('Format value with unit', () => {
639
642
  });
640
643
 
641
644
  describe('Format the value as human readable', () => {
642
- it.each(humanReadableTestCases)('formats the value with $unit', ({
643
- value,
644
- unit,
645
- expectedResult
646
- }) => {
647
- expect(
648
- formatMetricValueWithUnit({
649
- unit,
650
- value
651
- })
652
- ).toEqual(expectedResult);
653
- });
645
+ it.each(humanReadableTestCases)(
646
+ 'formats the value with $unit',
647
+ ({ value, unit, expectedResult }) => {
648
+ expect(
649
+ formatMetricValueWithUnit({
650
+ unit,
651
+ value
652
+ })
653
+ ).toEqual(expectedResult);
654
+ }
655
+ );
654
656
  });
655
657
 
656
658
  describe('Format the value as raw', () => {
657
- it.each(rawTestCases)('formats the value with $unit', ({
658
- value,
659
- unit,
660
- expectedResult
661
- }) => {
662
- expect(
663
- formatMetricValueWithUnit({
664
- isRaw: true,
665
- unit,
666
- value
667
- })
668
- ).toEqual(expectedResult);
669
- });
659
+ it.each(rawTestCases)(
660
+ 'formats the value with $unit',
661
+ ({ value, unit, expectedResult }) => {
662
+ expect(
663
+ formatMetricValueWithUnit({
664
+ isRaw: true,
665
+ unit,
666
+ value
667
+ })
668
+ ).toEqual(expectedResult);
669
+ }
670
+ );
670
671
  });
671
672
  });
@@ -947,32 +947,32 @@ export const getStackedLinesTimeSeriesPerStackAndUnit = ({
947
947
  };
948
948
 
949
949
  export {
950
- getTimeSeries,
950
+ bisectDate,
951
+ formatMetricValue,
952
+ formatMetricValueWithUnit,
953
+ getDates,
954
+ getInvertedStackedLines,
951
955
  getLineData,
952
- getMin,
956
+ getLineForMetric,
953
957
  getMax,
954
- getTime,
955
958
  getMetrics,
956
- getValueForMetric,
957
- getMetricValuesForUnit,
958
- getUnits,
959
- getDates,
960
- getLineForMetric,
961
959
  getMetricValuesForLines,
962
- getSortedStackedLines,
963
- getTimeSeriesForLines,
964
- getStackedMetricValues,
965
- getInvertedStackedLines,
960
+ getMetricValuesForUnit,
961
+ getMetricWithLatestData,
962
+ getMin,
966
963
  getNotInvertedStackedLines,
967
- hasUnitStackedLines,
968
- getYScale,
969
964
  getScale,
970
- getXScale,
971
- formatMetricValue,
965
+ getSortedStackedLines,
966
+ getStackedMetricValues,
967
+ getTime,
968
+ getTimeSeries,
969
+ getTimeSeriesForLines,
972
970
  getTimeValue,
973
- bisectDate,
974
- getMetricWithLatestData,
975
- formatMetricValueWithUnit,
971
+ getUnits,
972
+ getValueForMetric,
973
+ getXScale,
974
+ getYScale,
975
+ getYScalePerUnit,
976
976
  getYScaleUnit,
977
- getYScalePerUnit
977
+ hasUnitStackedLines
978
978
  };
@@ -113,7 +113,6 @@ const DataCell = ({
113
113
  if (!clickable) {
114
114
  return;
115
115
  }
116
- e.preventDefault();
117
116
  e.stopPropagation();
118
117
  }}
119
118
  {...commonCellProps}
@@ -130,7 +129,6 @@ const DataCell = ({
130
129
  if (!clickable) {
131
130
  return;
132
131
  }
133
- e.preventDefault();
134
132
  e.stopPropagation();
135
133
  }}
136
134
  style={{
@@ -15,6 +15,19 @@ const ButtonColumn = ({ row }: Props): JSX.Element => (
15
15
  <Button size="small">Click to reveal details about {row.name}</Button>
16
16
  );
17
17
 
18
+ const CheckboxColumn = ({ row }: Props): JSX.Element => {
19
+ const [checked, setChecked] = useState(false);
20
+
21
+ return (
22
+ <input
23
+ aria-label={`Toggle ${row.name}`}
24
+ checked={checked}
25
+ onChange={(event) => setChecked(event.target.checked)}
26
+ type="checkbox"
27
+ />
28
+ );
29
+ };
30
+
18
31
  const LargeText = (): JSX.Element => (
19
32
  <Typography sx={{ whiteSpace: 'normal' }}>
20
33
  This is a large text that fills the content
@@ -301,4 +314,36 @@ describe('Listing', () => {
301
314
 
302
315
  cy.makeSnapshot();
303
316
  });
317
+
318
+ it('toggles a checkbox in a clickable column without clicking the row', () => {
319
+ const onRowClick = cy.stub().as('rowClick');
320
+
321
+ cy.mount({
322
+ Component: (
323
+ <div style={{ height: '100vh' }}>
324
+ <Listing
325
+ columns={[
326
+ {
327
+ Component: CheckboxColumn,
328
+ clickable: true,
329
+ id: 'toggle',
330
+ label: 'Toggle',
331
+ type: ColumnType.component
332
+ }
333
+ ]}
334
+ currentPage={1}
335
+ limit={10}
336
+ onRowClick={onRowClick}
337
+ rows={[{ id: 0, name: 'E0' }]}
338
+ totalRows={1}
339
+ />
340
+ </div>
341
+ )
342
+ });
343
+
344
+ cy.findByLabelText('Toggle E0').click();
345
+
346
+ cy.findByLabelText('Toggle E0').should('be.checked');
347
+ cy.get('@rowClick').should('not.have.been.called');
348
+ });
304
349
  });
@@ -55,5 +55,5 @@ const StatusChip = ({
55
55
  );
56
56
  };
57
57
 
58
- export { SeverityCode, getStatusColors };
58
+ export { getStatusColors, SeverityCode };
59
59
  export default StatusChip;
package/src/api/index.ts CHANGED
@@ -66,4 +66,4 @@ const deleteData =
66
66
  })
67
67
  .then(({ data }) => data);
68
68
 
69
- export { getData, patchData, postData, putData, deleteData };
69
+ export { deleteData, getData, patchData, postData, putData };
@@ -63,4 +63,5 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
63
63
  );
64
64
  }
65
65
  );
66
+
66
67
  export { IconButton };
@@ -18,4 +18,4 @@ const useGlobalRefreshIntervalStyles = makeStyles()((theme) => ({
18
18
  }
19
19
  }));
20
20
 
21
- export { useStyles, useGlobalRefreshIntervalStyles };
21
+ export { useGlobalRefreshIntervalStyles, useStyles };
@@ -77,4 +77,4 @@ const useMenu = (): UseMenu => {
77
77
  };
78
78
  };
79
79
 
80
- export { useMenu, MenuProvider };
80
+ export { MenuProvider, useMenu };
@@ -1,3 +1,4 @@
1
+ import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
1
2
  import EditIcon from '@mui/icons-material/Edit';
2
3
  import VisibilityIcon from '@mui/icons-material/Visibility';
3
4
 
@@ -205,7 +206,7 @@ describe('SortableEntriesList - single input rows', () => {
205
206
  });
206
207
  });
207
208
 
208
- it('removes a row and moves the focus to the delete button of the row taking its place', () => {
209
+ it('removes a row and moves the focus to the first input of the row taking its place', () => {
209
210
  const { onChange } = initializeTemplates({
210
211
  templates: [options[0], options[1], options[2]]
211
212
  });
@@ -217,9 +218,7 @@ describe('SortableEntriesList - single input rows', () => {
217
218
  expect(getLastValues(onChange)).to.deep.equal([options[1], options[2]]);
218
219
  });
219
220
  cy.focused()
220
- .should('have.attr', 'data-testid', 'delete-row')
221
- .closest('li')
222
- .find('[data-testid="Template"]')
221
+ .should('have.attr', 'data-testid', 'Template')
223
222
  .should('have.value', options[1].name);
224
223
  });
225
224
 
@@ -326,15 +325,17 @@ const renderMacroRow = ({
326
325
  setField,
327
326
  value: macro
328
327
  }: RenderRowParams<MacroRow>): JSX.Element => (
329
- <div className="flex gap-2">
328
+ <div className="grid grid-cols-2 gap-2 @max-[600px]:grid-cols-1">
330
329
  <TextField
331
330
  dataTestId="macro-name"
331
+ fullWidth
332
332
  label="Name"
333
333
  onChange={(event): void => setField('name', event.target.value)}
334
334
  value={macro.name}
335
335
  />
336
336
  <TextField
337
337
  dataTestId="macro-value"
338
+ fullWidth
338
339
  label="Value"
339
340
  onChange={(event): void => setField('value', event.target.value)}
340
341
  type={macro.isPassword ? 'password' : 'text'}
@@ -504,3 +505,288 @@ describe('SortableEntriesList - multiple input rows', () => {
504
505
  getRows().eq(1).should('have.class', 'origin-fromTpl');
505
506
  });
506
507
  });
508
+
509
+ const buildMacros = (count: number): Array<MacroRow> =>
510
+ Array.from({ length: count }, (_, index) => ({
511
+ ...emptyMacro,
512
+ name: `MACRO_${index + 1}`
513
+ }));
514
+
515
+ const panelWidths = [1400, 900, 720, 560];
516
+
517
+ // The panel width is state, so changing it resizes the mounted list at runtime.
518
+ const ResizablePanel = ({
519
+ count,
520
+ maxVisibleRows,
521
+ onChangeSpy
522
+ }: {
523
+ count: number;
524
+ maxVisibleRows?: number;
525
+ onChangeSpy: Stub;
526
+ }): JSX.Element => {
527
+ const [width, setWidth] = useState(panelWidths[0]);
528
+
529
+ return (
530
+ <div className="p-4">
531
+ {panelWidths.map((panelWidth) => (
532
+ <button
533
+ data-testid={`width-${panelWidth}`}
534
+ key={panelWidth}
535
+ onClick={(): void => setWidth(panelWidth)}
536
+ type="button"
537
+ >
538
+ {panelWidth}
539
+ </button>
540
+ ))}
541
+ <div data-testid="panel" style={{ width }}>
542
+ <ControlledList<MacroRow>
543
+ actions={(): Array<SortableRowAction> => [
544
+ {
545
+ icon: <VisibilityIcon />,
546
+ id: 'toggle-password',
547
+ label: 'Toggle password',
548
+ onClick: () => undefined
549
+ },
550
+ {
551
+ icon: <DescriptionOutlinedIcon />,
552
+ id: 'macro-description',
553
+ label: 'Description',
554
+ onClick: () => undefined
555
+ }
556
+ ]}
557
+ createValue={(): MacroRow => emptyMacro}
558
+ initialValues={buildMacros(count)}
559
+ maxVisibleRows={maxVisibleRows}
560
+ onChangeSpy={onChangeSpy}
561
+ renderRow={renderMacroRow}
562
+ />
563
+ </div>
564
+ </div>
565
+ );
566
+ };
567
+
568
+ const initializePanel = ({
569
+ count,
570
+ maxVisibleRows
571
+ }: {
572
+ count: number;
573
+ maxVisibleRows?: number;
574
+ }): { onChange: Stub } => {
575
+ const onChange = cy.stub();
576
+
577
+ cy.viewport(1600, 900);
578
+ cy.mount({
579
+ Component: (
580
+ <ResizablePanel
581
+ count={count}
582
+ maxVisibleRows={maxVisibleRows}
583
+ onChangeSpy={onChange}
584
+ />
585
+ )
586
+ });
587
+
588
+ return { onChange };
589
+ };
590
+
591
+ const getScrollArea = (): Cypress.Chainable =>
592
+ cy.findByTestId('sortable-entries-scroll');
593
+
594
+ const getRect = (element: Element): DOMRect => element.getBoundingClientRect();
595
+
596
+ const getLastNames = (onChange: Stub): Array<string> =>
597
+ getLastValues<MacroRow>(onChange).map(({ name }) => name);
598
+
599
+ describe('SortableEntriesList - capped list', () => {
600
+ it('shows maxVisibleRows rows plus half of the next one, and keeps the add button outside the scroll area', () => {
601
+ initializePanel({ count: 15, maxVisibleRows: 10 });
602
+
603
+ getScrollArea().should(($scrollArea) => {
604
+ const scrollArea = $scrollArea[0];
605
+ const rows = scrollArea.querySelectorAll('li');
606
+ const visibleBottom = getRect(scrollArea).bottom;
607
+
608
+ expect(scrollArea.scrollHeight).to.be.greaterThan(
609
+ scrollArea.clientHeight
610
+ );
611
+ expect(getRect(rows[9]).bottom).to.be.at.most(visibleBottom);
612
+ expect(visibleBottom - getRect(rows[10]).top).to.be.closeTo(
613
+ getRect(rows[10]).height / 2,
614
+ 1
615
+ );
616
+ });
617
+
618
+ cy.findByTestId('Add')
619
+ .should('be.visible')
620
+ .closest('[data-testid="sortable-entries-scroll"]')
621
+ .should('not.exist');
622
+ });
623
+
624
+ it('fades the cut-off row only while rows are hidden below', () => {
625
+ initializePanel({ count: 15, maxVisibleRows: 10 });
626
+
627
+ getScrollArea().should('not.have.css', 'mask-image', 'none');
628
+ getScrollArea().scrollTo('bottom');
629
+ getScrollArea().should('have.css', 'mask-image', 'none');
630
+ });
631
+
632
+ it('does not cap a list shorter than maxVisibleRows', () => {
633
+ initializePanel({ count: 5, maxVisibleRows: 10 });
634
+
635
+ getScrollArea()
636
+ .should('have.css', 'max-height', 'none')
637
+ .should(($scrollArea) => {
638
+ expect($scrollArea[0].scrollHeight).to.equal(
639
+ $scrollArea[0].clientHeight
640
+ );
641
+ });
642
+ });
643
+
644
+ it('focuses and scrolls to a row added at the end of a capped list', () => {
645
+ initializePanel({ count: 15, maxVisibleRows: 10 });
646
+
647
+ cy.findByTestId('Add').click();
648
+
649
+ getRows().should('have.length', 16);
650
+ cy.focused()
651
+ .should('have.attr', 'data-testid', 'macro-name')
652
+ .should(($input) => {
653
+ const scrollAreaRect = getRect(
654
+ $input[0].closest(
655
+ '[data-testid="sortable-entries-scroll"]'
656
+ ) as Element
657
+ );
658
+ const inputRect = getRect($input[0]);
659
+
660
+ expect(inputRect.top).to.be.at.least(scrollAreaRect.top);
661
+ expect(inputRect.bottom).to.be.at.most(scrollAreaRect.bottom);
662
+ });
663
+ });
664
+
665
+ it('scrolls to the focused new row when adding it makes the list reach the cap', () => {
666
+ initializePanel({ count: 10, maxVisibleRows: 10 });
667
+
668
+ getScrollArea().should('have.css', 'max-height', 'none');
669
+ cy.findByTestId('Add').click();
670
+
671
+ getRows().should('have.length', 11);
672
+ getScrollArea().should('not.have.css', 'max-height', 'none');
673
+ cy.focused()
674
+ .should('have.attr', 'data-testid', 'macro-name')
675
+ .closest('li')
676
+ .should(($row) => {
677
+ const scrollAreaRect = getRect(
678
+ $row[0].closest('[data-testid="sortable-entries-scroll"]') as Element
679
+ );
680
+
681
+ // 1px tolerance for sub-pixel row heights.
682
+ expect(getRect($row[0]).bottom).to.be.at.most(
683
+ scrollAreaRect.bottom + 1
684
+ );
685
+ });
686
+ getScrollArea().should('have.css', 'mask-image', 'none');
687
+ });
688
+
689
+ it('moves a row past the visible area with the keyboard, scrolling the list', () => {
690
+ const { onChange } = initializePanel({ count: 15, maxVisibleRows: 10 });
691
+
692
+ cy.moveSortableElement({
693
+ direction: 'down',
694
+ element: cy.findAllByTestId('drag-handle').eq(0),
695
+ times: 12
696
+ });
697
+
698
+ cy.wrap(onChange).should(() => {
699
+ expect(getLastNames(onChange).indexOf('MACRO_1')).to.equal(12);
700
+ });
701
+ getScrollArea().its('0.scrollTop').should('be.greaterThan', 0);
702
+ });
703
+
704
+ it('auto-scrolls while a row is dragged with the pointer near the bottom edge', () => {
705
+ const { onChange } = initializePanel({ count: 15, maxVisibleRows: 10 });
706
+
707
+ cy.findAllByTestId('drag-handle')
708
+ .eq(0)
709
+ .then(($handle) => {
710
+ const { left, top, width, height } = getRect($handle[0]);
711
+ const pointer = {
712
+ button: 0,
713
+ clientX: left + width / 2,
714
+ isPrimary: true,
715
+ pointerId: 1
716
+ };
717
+
718
+ cy.wrap($handle).trigger('pointerdown', {
719
+ ...pointer,
720
+ clientY: top + height / 2
721
+ });
722
+
723
+ getScrollArea().then(($scrollArea) => {
724
+ const clientY = getRect($scrollArea[0]).bottom - 5;
725
+
726
+ cy.document().trigger('pointermove', { ...pointer, clientY });
727
+ getScrollArea().its('0.scrollTop').should('be.greaterThan', 100);
728
+ cy.document().trigger('pointermove', { ...pointer, clientY });
729
+ cy.document().trigger('pointerup', { ...pointer, clientY });
730
+ });
731
+ });
732
+
733
+ cy.wrap(onChange).should(() => {
734
+ expect(getLastNames(onChange).indexOf('MACRO_1')).to.be.greaterThan(10);
735
+ });
736
+ });
737
+ });
738
+
739
+ describe('SortableEntriesList - container responsive rows', () => {
740
+ const getFieldRects = (): Cypress.Chainable<Array<DOMRect>> =>
741
+ getRows()
742
+ .eq(0)
743
+ .find('.MuiFormControl-root')
744
+ .then(($fields) => $fields.toArray().map(getRect));
745
+
746
+ it('keeps the fields and the four buttons on one line at 1400, 900 and 720px, resized at runtime', () => {
747
+ initializePanel({ count: 3 });
748
+
749
+ [1400, 900, 720, 1400].forEach((width) => {
750
+ cy.findByTestId(`width-${width}`).click();
751
+ cy.findByTestId('panel').should('have.css', 'width', `${width}px`);
752
+
753
+ getFieldRects().should(([name, value]) => {
754
+ expect(value.top).to.equal(name.top);
755
+ expect(name.width).to.be.at.least(275);
756
+ expect(value.width).to.be.at.least(275);
757
+ });
758
+
759
+ cy.get('[data-row-fields]')
760
+ .eq(0)
761
+ .next()
762
+ .find('button')
763
+ .should('have.length', 4)
764
+ .should(($buttons) => {
765
+ const rects = $buttons.toArray().map(getRect);
766
+ const panelRight = getRect(
767
+ Cypress.$('[data-testid="panel"]')[0]
768
+ ).right;
769
+
770
+ rects.slice(1).forEach((rect, index) => {
771
+ expect(rect.top).to.equal(rects[index].top);
772
+ expect(rect.left - rects[index].left).to.equal(32);
773
+ });
774
+ expect(rects[3].right).to.be.at.most(panelRight);
775
+ });
776
+ });
777
+ });
778
+
779
+ it('stacks the fields when the list gets narrower than 600px, whatever the viewport', () => {
780
+ initializePanel({ count: 3 });
781
+
782
+ cy.findByTestId('width-560').click();
783
+ getFieldRects().should(([name, value]) => {
784
+ expect(value.top).to.be.greaterThan(name.bottom);
785
+ });
786
+
787
+ cy.findByTestId('width-900').click();
788
+ getFieldRects().should(([name, value]) => {
789
+ expect(value.top).to.equal(name.top);
790
+ });
791
+ });
792
+ });
@@ -1,3 +1,4 @@
1
+ import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
1
2
  import EditIcon from '@mui/icons-material/Edit';
2
3
  import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
3
4
  import VisibilityIcon from '@mui/icons-material/Visibility';
@@ -90,7 +91,10 @@ const renderTemplateRow = ({
90
91
  };
91
92
 
92
93
  type TemplatesListArgs = Partial<
93
- Pick<SortableEntriesListProps<TemplateRow>, 'addLabel' | 'draggable'>
94
+ Pick<
95
+ SortableEntriesListProps<TemplateRow>,
96
+ 'addLabel' | 'draggable' | 'maxVisibleRows'
97
+ >
94
98
  >;
95
99
 
96
100
  const TemplatesList = (args: TemplatesListArgs): JSX.Element => {
@@ -132,10 +136,11 @@ const TemplatesList = (args: TemplatesListArgs): JSX.Element => {
132
136
  };
133
137
 
134
138
  export const Templates: Story = {
135
- args: { draggable: true },
139
+ args: { draggable: true, maxVisibleRows: 10 },
136
140
  argTypes: {
137
141
  addLabel: { control: 'text' },
138
- draggable: { control: 'boolean' }
142
+ draggable: { control: 'boolean' },
143
+ maxVisibleRows: { control: 'number' }
139
144
  },
140
145
  parameters: { mockData: templatesMockData },
141
146
  render: (args) => <TemplatesList {...(args as TemplatesListArgs)} />
@@ -167,7 +172,7 @@ const renderMacroRow = ({
167
172
  setField,
168
173
  value: macro
169
174
  }: RenderRowParams<MacroRow>): JSX.Element => (
170
- <div className="grid grid-cols-2 gap-2">
175
+ <div className="grid grid-cols-2 gap-2 @max-[600px]:grid-cols-1">
171
176
  <TextField
172
177
  dataTestId="macro-name"
173
178
  fullWidth
@@ -197,12 +202,22 @@ const initialMacros: Array<MacroRow> = [
197
202
  { isPassword: false, name: 'PORT', origin: 'fromCommand', value: '443' }
198
203
  ];
199
204
 
205
+ const buildMacros = (count: number): Array<MacroRow> =>
206
+ Array.from({ length: count }, (_, index) => ({
207
+ isPassword: false,
208
+ name: `MACRO_${index + 1}`,
209
+ origin: 'direct',
210
+ value: `value ${index + 1}`
211
+ }));
212
+
200
213
  const MacrosList = ({
201
214
  draggable,
202
- initialValues = initialMacros
215
+ initialValues = initialMacros,
216
+ maxVisibleRows
203
217
  }: {
204
218
  draggable: boolean;
205
219
  initialValues?: Array<MacroRow>;
220
+ maxVisibleRows?: number;
206
221
  }): JSX.Element => {
207
222
  const [macros, setMacros] = useState(initialValues);
208
223
 
@@ -216,7 +231,7 @@ const MacrosList = ({
216
231
  );
217
232
 
218
233
  return (
219
- <div className="w-[640px]">
234
+ <>
220
235
  <SortableEntriesList<MacroRow>
221
236
  actions={({ index, value }) => [
222
237
  {
@@ -224,29 +239,87 @@ const MacrosList = ({
224
239
  id: 'toggle-password',
225
240
  label: value.isPassword ? 'Show value' : 'Hide value',
226
241
  onClick: () => toggleIsPassword(index)
242
+ },
243
+ {
244
+ icon: <DescriptionOutlinedIcon />,
245
+ id: 'macro-description',
246
+ label: 'Description',
247
+ onClick: () => undefined
227
248
  }
228
249
  ]}
229
250
  createValue={createMacroRow}
230
251
  draggable={draggable}
231
252
  getRowClassName={({ value }) => macroRowClassName[value.origin]}
232
253
  label="Custom macros"
254
+ maxVisibleRows={maxVisibleRows}
233
255
  onChange={setMacros}
234
256
  renderRow={renderMacroRow}
235
257
  values={macros}
236
258
  />
237
259
  <ValuesPreview values={macros} />
238
- </div>
260
+ </>
239
261
  );
240
262
  };
241
263
 
242
264
  export const CustomMacros: Story = {
243
- render: () => <MacrosList draggable />
265
+ render: () => (
266
+ <div className="w-[640px]">
267
+ <MacrosList draggable />
268
+ </div>
269
+ )
244
270
  };
245
271
 
246
272
  export const NonDraggable: Story = {
247
- render: () => <MacrosList draggable={false} />
273
+ render: () => (
274
+ <div className="w-[640px]">
275
+ <MacrosList draggable={false} />
276
+ </div>
277
+ )
248
278
  };
249
279
 
250
280
  export const Empty: Story = {
251
- render: () => <MacrosList draggable initialValues={[]} />
281
+ render: () => (
282
+ <div className="w-[640px]">
283
+ <MacrosList draggable initialValues={[]} />
284
+ </div>
285
+ )
286
+ };
287
+
288
+ // Capped list in a resizable container: drag the bottom-right corner or use the
289
+ // presets to check the row layout against the list width, not the viewport.
290
+ const panelWidths = [1400, 900, 720, 560];
291
+
292
+ const ScrollableMacrosList = (): JSX.Element => {
293
+ const [width, setWidth] = useState(panelWidths[0]);
294
+
295
+ return (
296
+ <div className="flex flex-col gap-2">
297
+ <div className="flex gap-2">
298
+ {panelWidths.map((panelWidth) => (
299
+ <button
300
+ className="rounded border px-2 py-1"
301
+ key={panelWidth}
302
+ onClick={(): void => setWidth(panelWidth)}
303
+ type="button"
304
+ >
305
+ {panelWidth}px
306
+ </button>
307
+ ))}
308
+ </div>
309
+ <div
310
+ className="max-w-full resize-x overflow-auto border border-dashed p-2"
311
+ style={{ width }}
312
+ >
313
+ <MacrosList
314
+ draggable
315
+ initialValues={buildMacros(15)}
316
+ maxVisibleRows={10}
317
+ />
318
+ </div>
319
+ </div>
320
+ );
321
+ };
322
+
323
+ export const ScrollableAndResizable: Story = {
324
+ render: () => <ScrollableMacrosList />
252
325
  };
@@ -29,6 +29,7 @@ import {
29
29
  labelRowMovedOver,
30
30
  labelRowPickedUp
31
31
  } from './translatedLabels';
32
+ import { useMaxVisibleRows } from './useMaxVisibleRows';
32
33
  import { useSortableEntries } from './useSortableEntries';
33
34
 
34
35
  export interface Props<T> {
@@ -42,8 +43,17 @@ export interface Props<T> {
42
43
  getRowClassName?: (params: RowParams<T>) => string | undefined;
43
44
  /** Accessible name of the list. */
44
45
  label?: string;
46
+ /**
47
+ * Caps the list to this many rows, the next one half visible, and scrolls
48
+ * the rest. The add button stays outside the scroll area.
49
+ */
50
+ maxVisibleRows?: number;
45
51
  onChange: (values: Array<T>) => void;
46
- /** Renders the inputs of one row. `values` gives access to sibling rows. */
52
+ /**
53
+ * Renders the inputs of one row. `values` gives access to sibling rows. The
54
+ * list is a CSS container, so the row layout can adapt to the list width
55
+ * with container query classes (e.g. `@max-[600px]:grid-cols-1`).
56
+ */
47
57
  renderRow: (params: RenderRowParams<T>) => ReactNode;
48
58
  values: Array<T>;
49
59
  }
@@ -60,6 +70,7 @@ export const SortableEntriesList = <T,>({
60
70
  draggable = true,
61
71
  getRowClassName,
62
72
  label,
73
+ maxVisibleRows,
63
74
  onChange,
64
75
  renderRow,
65
76
  values
@@ -81,10 +92,21 @@ export const SortableEntriesList = <T,>({
81
92
  values
82
93
  });
83
94
 
95
+ const {
96
+ hasHiddenRowsBelow,
97
+ listRef,
98
+ maxHeight,
99
+ scrollRef,
100
+ updateHiddenRowsBelow
101
+ } = useMaxVisibleRows(maxVisibleRows);
102
+
84
103
  const sensors = useSensors(
85
104
  useSensor(PointerSensor),
86
105
  useSensor(KeyboardSensor, {
87
- coordinateGetter: sortableKeyboardCoordinates
106
+ coordinateGetter: sortableKeyboardCoordinates,
107
+ // Instant scroll keeps each key press based on up-to-date positions in a
108
+ // capped list.
109
+ scrollBehavior: 'auto'
88
110
  })
89
111
  );
90
112
 
@@ -113,7 +135,7 @@ export const SortableEntriesList = <T,>({
113
135
  };
114
136
 
115
137
  return (
116
- <div ref={containerRef}>
138
+ <div className="@container w-full" ref={containerRef}>
117
139
  <ItemComposition labelAdd={translatedAddLabel} onAddItem={addEntry}>
118
140
  {[
119
141
  <DndContext
@@ -133,28 +155,41 @@ export const SortableEntriesList = <T,>({
133
155
  items={entryIds}
134
156
  strategy={verticalListSortingStrategy}
135
157
  >
136
- <ul
137
- aria-label={label}
138
- className="m-0 flex w-full list-none flex-col gap-2 p-0"
158
+ <div
159
+ className={
160
+ maxVisibleRows
161
+ ? `relative w-full overflow-y-auto pt-2 [scrollbar-gutter:stable] [scrollbar-width:thin] ${hasHiddenRowsBelow ? '[mask-image:linear-gradient(to_bottom,#000_calc(100%-2rem),transparent)]' : ''}`
162
+ : 'w-full'
163
+ }
164
+ data-testid="sortable-entries-scroll"
165
+ onScroll={updateHiddenRowsBelow}
166
+ ref={scrollRef}
167
+ style={{ maxHeight }}
139
168
  >
140
- {entries.map(({ id, value }, index) => {
141
- const rowParams = { index, value, values };
169
+ <ul
170
+ aria-label={label}
171
+ className="m-0 flex w-full list-none flex-col gap-2 p-0"
172
+ ref={listRef}
173
+ >
174
+ {entries.map(({ id, value }, index) => {
175
+ const rowParams = { index, value, values };
142
176
 
143
- return (
144
- <Row<T>
145
- actions={actions?.(rowParams) ?? []}
146
- className={getRowClassName?.(rowParams)}
147
- draggable={draggable}
148
- id={id}
149
- key={id}
150
- onDelete={removeEntry}
151
- onUpdate={updateEntry}
152
- renderRow={renderRow}
153
- rowParams={rowParams}
154
- />
155
- );
156
- })}
157
- </ul>
177
+ return (
178
+ <Row<T>
179
+ actions={actions?.(rowParams) ?? []}
180
+ className={getRowClassName?.(rowParams)}
181
+ draggable={draggable}
182
+ id={id}
183
+ key={id}
184
+ onDelete={removeEntry}
185
+ onUpdate={updateEntry}
186
+ renderRow={renderRow}
187
+ rowParams={rowParams}
188
+ />
189
+ );
190
+ })}
191
+ </ul>
192
+ </div>
158
193
  </SortableContext>
159
194
  </DndContext>
160
195
  ]}
@@ -0,0 +1,77 @@
1
+ import { type RefObject, useLayoutEffect, useRef, useState } from 'react';
2
+
3
+ interface Result {
4
+ hasHiddenRowsBelow: boolean;
5
+ listRef: RefObject<HTMLUListElement | null>;
6
+ maxHeight?: number;
7
+ scrollRef: RefObject<HTMLDivElement | null>;
8
+ updateHiddenRowsBelow: () => void;
9
+ }
10
+
11
+ // Caps the list height to `maxVisibleRows` rows, measured from the rendered
12
+ // rows so it stays right whatever the row height (content, container width).
13
+ export const useMaxVisibleRows = (maxVisibleRows?: number): Result => {
14
+ const scrollRef = useRef<HTMLDivElement>(null);
15
+ const listRef = useRef<HTMLUListElement>(null);
16
+ const [maxHeight, setMaxHeight] = useState<number>();
17
+ const [hasHiddenRowsBelow, setHasHiddenRowsBelow] = useState(false);
18
+
19
+ const updateHiddenRowsBelow = (): void => {
20
+ const container = scrollRef.current;
21
+
22
+ setHasHiddenRowsBelow(
23
+ !!container &&
24
+ container.scrollHeight - container.scrollTop - container.clientHeight >
25
+ 1
26
+ );
27
+ };
28
+
29
+ useLayoutEffect(() => {
30
+ const list = listRef.current;
31
+
32
+ if (!maxVisibleRows || !list) {
33
+ setMaxHeight(undefined);
34
+
35
+ return undefined;
36
+ }
37
+
38
+ const observer = new ResizeObserver(() => {
39
+ const cutOffRow = list.children[maxVisibleRows] as
40
+ | HTMLElement
41
+ | undefined;
42
+
43
+ // Half of the first row past the cap stays visible, so the cut reads as
44
+ // a deliberate hint that the list scrolls.
45
+ setMaxHeight(
46
+ cutOffRow
47
+ ? Math.round(cutOffRow.offsetTop + cutOffRow.offsetHeight / 2)
48
+ : undefined
49
+ );
50
+ updateHiddenRowsBelow();
51
+ });
52
+
53
+ observer.observe(list);
54
+
55
+ return () => observer.disconnect();
56
+ }, [maxVisibleRows]);
57
+
58
+ useLayoutEffect(() => {
59
+ // The cap can apply after a row was added and focused (e.g. the 11th row
60
+ // of a list capped to 10): bring the focused row back into view.
61
+ const focusedRow = document.activeElement?.closest('li');
62
+
63
+ if (focusedRow && scrollRef.current?.contains(focusedRow)) {
64
+ focusedRow.scrollIntoView({ block: 'nearest' });
65
+ }
66
+
67
+ updateHiddenRowsBelow();
68
+ }, [maxHeight]);
69
+
70
+ return {
71
+ hasHiddenRowsBelow,
72
+ listRef,
73
+ maxHeight,
74
+ scrollRef,
75
+ updateHiddenRowsBelow
76
+ };
77
+ };
@@ -77,7 +77,12 @@ export const useSortableEntries = <T>({
77
77
  }
78
78
 
79
79
  pendingFocusSelectorRef.current = null;
80
- containerRef.current?.querySelector<HTMLElement>(selector)?.focus();
80
+
81
+ const element = containerRef.current?.querySelector<HTMLElement>(selector);
82
+
83
+ element?.focus();
84
+ // In a capped list, show the whole row, not only the focused element.
85
+ element?.closest('li')?.scrollIntoView({ block: 'nearest' });
81
86
  }, [entries]);
82
87
 
83
88
  const commit = (nextEntries: Array<Entry<T>>): void => {
@@ -108,7 +113,7 @@ export const useSortableEntries = <T>({
108
113
  currentEntries[index + 1] ?? currentEntries[index - 1];
109
114
 
110
115
  pendingFocusSelectorRef.current = nextFocusedEntry
111
- ? `${getRowSelector(nextFocusedEntry.id)} [data-testid="delete-row"]`
116
+ ? `${getRowSelector(nextFocusedEntry.id)} [data-row-fields] :is(${focusableSelector})`
112
117
  : `button[aria-label="${CSS.escape(addLabel)}"]`;
113
118
  commit(currentEntries.filter((entry) => entry.id !== id));
114
119
  };
package/src/index.ts CHANGED
@@ -1,11 +1,5 @@
1
1
  import type { Props as SingleAutocompleteFieldProps } from './InputField/Select/Autocomplete';
2
2
 
3
- export { default as IconButton } from './Button/Icon';
4
- export { Checkbox, CheckboxGroup } from './Checkbox';
5
- export { default as SingleAutocompleteField } from './InputField/Select/Autocomplete';
6
- export { default as Pagination } from './Pagination';
7
- export type { SingleAutocompleteFieldProps };
8
-
9
3
  export type { ActionsListActions } from './ActionsList';
10
4
  export {
11
5
  ActionsListActionDivider,
@@ -46,7 +40,9 @@ export {
46
40
  export { WidgetResourceType as ResourceType } from './api/useGraphQuery/models';
47
41
  export { default as useMutationQuery, Method } from './api/useMutationQuery';
48
42
  export { default as useRequest } from './api/useRequest';
43
+ export { default as IconButton } from './Button/Icon';
49
44
  export { default as SaveButton } from './Button/Save';
45
+ export { Checkbox, CheckboxGroup } from './Checkbox';
50
46
  export { default as ContentWithCircularLoading } from './ContentWithCircularProgress';
51
47
  export * from './Dashboard';
52
48
  export { default as Dialog } from './Dialog';
@@ -78,6 +74,7 @@ export { default as PersistentTooltip } from './InputField/Search/PersistentTool
78
74
  export { default as RegexpHelpTooltip } from './InputField/Search/RegexpHelpTooltip';
79
75
  export type { SelectEntry } from './InputField/Select';
80
76
  export { default as SelectField } from './InputField/Select';
77
+ export { default as SingleAutocompleteField } from './InputField/Select/Autocomplete';
81
78
  export { default as MultiConnectedAutocompleteField } from './InputField/Select/Autocomplete/Connected/Multi/index';
82
79
  export { default as PopoverMultiConnectedAutocompleteField } from './InputField/Select/Autocomplete/Connected/Multi/Popover';
83
80
  export { default as SingleConnectedAutocompleteField } from './InputField/Select/Autocomplete/Connected/Single';
@@ -112,6 +109,7 @@ export { default as Module } from './Module/index';
112
109
  export { default as LicensedModule } from './Module/LicensedModule';
113
110
  export { default as MultiSelectEntries } from './MultiSelectEntries';
114
111
  export { default as PageSkeleton } from './PageSkeleton';
112
+ export { default as Pagination } from './Pagination';
115
113
  export { default as Panel } from './Panel';
116
114
  export { default as MemoizedPanel } from './Panel/Memoized';
117
115
  export { default as SectionPanel, MemoizedSectionPanel } from './Panel/Section';
@@ -145,3 +143,4 @@ export { default as Subtitle } from './Typography/Subtitle';
145
143
  export * from './utils';
146
144
  export { default as WallpaperPage } from './WallpaperPage';
147
145
  export { default as Wizard } from './Wizard';
146
+ export type { SingleAutocompleteFieldProps };
@@ -41,4 +41,4 @@ const getUrlQueryParameters = <
41
41
  return fromPairs(entries) as TQueryParameters;
42
42
  };
43
43
 
44
- export { setUrlQueryParameters, getUrlQueryParameters };
44
+ export { getUrlQueryParameters, setUrlQueryParameters };
@@ -92,4 +92,4 @@ const useLocaleDateTimeFormat = (): LocaleDateTimeFormat => {
92
92
  };
93
93
  };
94
94
 
95
- export { dateTimeFormat, dateFormat, timeFormat, useLocaleDateTimeFormat };
95
+ export { dateFormat, dateTimeFormat, timeFormat, useLocaleDateTimeFormat };