@centreon/ui 26.10.5 → 26.10.7
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 +1 -1
- package/src/components/Layout/PageLayout/PageQuickAccess.tsx +28 -10
- package/src/components/Layout/PageLayout/translatedLabels.ts +1 -0
- package/src/components/SortableEntriesList/SortableEntriesList.cypress.spec.tsx +291 -5
- package/src/components/SortableEntriesList/SortableEntriesList.stories.tsx +83 -10
- package/src/components/SortableEntriesList/SortableEntriesList.tsx +58 -23
- package/src/components/SortableEntriesList/useMaxVisibleRows.ts +77 -0
- package/src/components/SortableEntriesList/useSortableEntries.ts +7 -2
- package/src/utils/useInfiniteScrollListing.ts +11 -7
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import AddIcon from '@mui/icons-material/Add';
|
|
2
2
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|
3
|
+
import { CircularProgress } from '@mui/material';
|
|
3
4
|
|
|
4
5
|
import { useTranslation } from 'react-i18next';
|
|
5
6
|
|
|
6
7
|
import { Button, Menu } from '../..';
|
|
8
|
+
import { labelLoading } from './translatedLabels';
|
|
7
9
|
|
|
8
10
|
interface NamedEntity {
|
|
9
11
|
id: number | string;
|
|
@@ -16,10 +18,13 @@ type Props = {
|
|
|
16
18
|
goBack: () => void;
|
|
17
19
|
isActive: (id: number | string) => boolean;
|
|
18
20
|
isDisabled?: (id: number | string) => boolean;
|
|
21
|
+
isLoading?: boolean;
|
|
19
22
|
labels: {
|
|
20
23
|
create: string;
|
|
21
24
|
goBack: string;
|
|
22
25
|
};
|
|
26
|
+
// Observed at the bottom of the list to load the next page when it is reached
|
|
27
|
+
loadMoreRef?: (node: Element | null) => void;
|
|
23
28
|
navigateToElement: (id: number | string) => () => void;
|
|
24
29
|
};
|
|
25
30
|
|
|
@@ -27,6 +32,8 @@ export const PageQuickAccess = ({
|
|
|
27
32
|
elements,
|
|
28
33
|
isActive,
|
|
29
34
|
isDisabled,
|
|
35
|
+
isLoading,
|
|
36
|
+
loadMoreRef,
|
|
30
37
|
navigateToElement,
|
|
31
38
|
goBack,
|
|
32
39
|
create,
|
|
@@ -38,16 +45,27 @@ export const PageQuickAccess = ({
|
|
|
38
45
|
<Menu>
|
|
39
46
|
<Menu.Button data-testid="quickaccess" />
|
|
40
47
|
<Menu.Items>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
<div
|
|
49
|
+
className="max-h-[50vh] overflow-y-auto"
|
|
50
|
+
data-testid="quickaccess-elements"
|
|
51
|
+
>
|
|
52
|
+
{elements?.map((element) => (
|
|
53
|
+
<Menu.Item
|
|
54
|
+
isActive={isActive(element.id)}
|
|
55
|
+
isDisabled={isDisabled?.(element.id)}
|
|
56
|
+
key={`${element.id}`}
|
|
57
|
+
onClick={navigateToElement(element.id)}
|
|
58
|
+
>
|
|
59
|
+
{element.name}
|
|
60
|
+
</Menu.Item>
|
|
61
|
+
))}
|
|
62
|
+
{loadMoreRef && <div className="h-px" ref={loadMoreRef} />}
|
|
63
|
+
{isLoading && (
|
|
64
|
+
<div className="flex justify-center py-2">
|
|
65
|
+
<CircularProgress aria-label={t(labelLoading)} size={20} />
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
51
69
|
<Menu.Divider key="divider" />
|
|
52
70
|
<div className="px-2 pb-2 flex gap-4">
|
|
53
71
|
<Button
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const labelLoading = 'Loading...';
|
|
@@ -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
|
|
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', '
|
|
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="
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
260
|
+
</>
|
|
239
261
|
);
|
|
240
262
|
};
|
|
241
263
|
|
|
242
264
|
export const CustomMacros: Story = {
|
|
243
|
-
render: () =>
|
|
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: () =>
|
|
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: () =>
|
|
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
|
-
/**
|
|
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
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
};
|
|
@@ -49,6 +49,15 @@ export const useInfiniteScrollListing = <T>({
|
|
|
49
49
|
|
|
50
50
|
const [page, setPage] = useAtom(pageAtom);
|
|
51
51
|
|
|
52
|
+
// Shared by the active query and the next page prefetch, so that the
|
|
53
|
+
// prefetched page is reused instead of being requested again
|
|
54
|
+
const getQueryKey = (queryPage: number): Array<string | number> => [
|
|
55
|
+
queryKeyName,
|
|
56
|
+
queryPage,
|
|
57
|
+
JSON.stringify(parameters),
|
|
58
|
+
JSON.stringify(customQueryParameters)
|
|
59
|
+
];
|
|
60
|
+
|
|
52
61
|
const { data, isLoading, prefetchNextPage, fetchStatus } = useFetchQuery<
|
|
53
62
|
Listing<T>
|
|
54
63
|
>({
|
|
@@ -59,12 +68,7 @@ export const useInfiniteScrollListing = <T>({
|
|
|
59
68
|
customQueryParameters,
|
|
60
69
|
parameters: { limit, page: params?.page || page, ...parameters }
|
|
61
70
|
}),
|
|
62
|
-
getQueryKey: () =>
|
|
63
|
-
queryKeyName,
|
|
64
|
-
page,
|
|
65
|
-
JSON.stringify(parameters),
|
|
66
|
-
JSON.stringify(customQueryParameters)
|
|
67
|
-
],
|
|
71
|
+
getQueryKey: () => getQueryKey(page),
|
|
68
72
|
isPaginated: true,
|
|
69
73
|
queryOptions: {
|
|
70
74
|
enabled,
|
|
@@ -110,7 +114,7 @@ export const useInfiniteScrollListing = <T>({
|
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
prefetchNextPage({
|
|
113
|
-
getPrefetchQueryKey:
|
|
117
|
+
getPrefetchQueryKey: getQueryKey,
|
|
114
118
|
page
|
|
115
119
|
});
|
|
116
120
|
}, [data, limit, page, prefetchNextPage]);
|