@centreon/ui 25.3.4 → 25.4.1

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": "25.3.4",
3
+ "version": "25.4.1",
4
4
  "description": "Centreon UI Components",
5
5
  "scripts": {
6
6
  "update:deps": "pnpx npm-check-updates -i --format group",
@@ -3,8 +3,8 @@ import { useCallback, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { makeStyles } from 'tss-react/mui';
5
5
 
6
- import ChevronRightIcon from '@mui/icons-material/ChevronRight';
7
- import ExpandMore from '@mui/icons-material/ExpandMore';
6
+ import { ExpandLess, ExpandMore } from '@mui/icons-material';
7
+
8
8
  import {
9
9
  Box,
10
10
  Collapse,
@@ -66,7 +66,7 @@ const CollapsibleGroup = ({
66
66
 
67
67
  const containerClassName = className || '';
68
68
 
69
- const CollapseIcon = isOpen ? ExpandMore : ChevronRightIcon;
69
+ const CollapseIcon = isOpen ? ExpandLess : ExpandMore;
70
70
  const ContainerComponent = useCallback(
71
71
  ({
72
72
  children: containerComponentChildren
@@ -143,6 +143,8 @@ const ConnectedAutocomplete = ({
143
143
  value={value ?? null}
144
144
  onBlur={blur}
145
145
  onChange={changeAutocomplete}
146
+ disableSelectAll={connectedAutocomplete?.disableSelectAll}
147
+ limitTags={connectedAutocomplete?.limitTags}
146
148
  />
147
149
  ),
148
150
  memoProps: [
@@ -203,6 +203,8 @@ const Inputs = ({
203
203
  ? find(propEq(groupName, 'name'), groups)
204
204
  : ({} as Group);
205
205
 
206
+ const hasGroupDivider = !groups[index]?.isDividerHidden;
207
+
206
208
  const isFirstElement = areGroupsOpen || equals(index, 0);
207
209
 
208
210
  return (
@@ -253,17 +255,19 @@ const Inputs = ({
253
255
  </div>
254
256
  </CollapsibleGroup>
255
257
  </div>
256
- {hasGroupTitle && not(equals(lastGroup, groupName as string)) && (
257
- <Divider
258
- flexItem
259
- className={classes.divider}
260
- orientation={
261
- equals(groupDirection, GroupDirection.Horizontal)
262
- ? 'vertical'
263
- : 'horizontal'
264
- }
265
- />
266
- )}
258
+ {hasGroupDivider &&
259
+ hasGroupTitle &&
260
+ not(equals(lastGroup, groupName as string)) && (
261
+ <Divider
262
+ flexItem
263
+ className={classes.divider}
264
+ orientation={
265
+ equals(groupDirection, GroupDirection.Horizontal)
266
+ ? 'vertical'
267
+ : 'horizontal'
268
+ }
269
+ />
270
+ )}
267
271
  </Fragment>
268
272
  );
269
273
  })}
@@ -59,6 +59,8 @@ export interface InputProps {
59
59
  endpoint?: string;
60
60
  filterKey?: string;
61
61
  getRenderedOptionText?: (option) => string | JSX.Element;
62
+ disableSelectAll?: boolean;
63
+ limitTags?: number;
62
64
  };
63
65
  file?: {
64
66
  multiple?: boolean;
@@ -133,4 +135,5 @@ export interface Group {
133
135
  name: string;
134
136
  order: number;
135
137
  titleAttributes?: TypographyProps;
138
+ isDividerHidden?: boolean;
136
139
  }
@@ -4,12 +4,40 @@ import {
4
4
  TestQueryProvider
5
5
  } from '@centreon/ui';
6
6
 
7
+ import i18next from 'i18next';
8
+ import { useState } from 'react';
9
+ import { initReactI18next } from 'react-i18next';
10
+ import { labelSelectAll, labelUnSelectAll } from '../../../../translatedLabels';
7
11
  import { baseEndpoint, getEndpoint, label, placeholder } from './utils';
8
12
 
9
13
  const optionOne = 'My Option 1';
10
14
 
15
+ const Component = () => {
16
+ const [values, setValues] = useState([]);
17
+ return (
18
+ <TestQueryProvider>
19
+ <div style={{ paddingTop: 20 }}>
20
+ <MultiConnectedAutocompleteField
21
+ field="host.name"
22
+ getEndpoint={getEndpoint}
23
+ label={label}
24
+ placeholder={placeholder}
25
+ value={values}
26
+ onChange={(_, item) => setValues(item)}
27
+ disableSelectAll={false}
28
+ />
29
+ </div>
30
+ </TestQueryProvider>
31
+ );
32
+ };
33
+
11
34
  describe('Multi connected autocomplete', () => {
12
35
  beforeEach(() => {
36
+ i18next.use(initReactI18next).init({
37
+ lng: 'en',
38
+ resources: {}
39
+ });
40
+
13
41
  cy.fixture('inputField/listOptions').then((optionsData) => {
14
42
  cy.interceptAPIRequest({
15
43
  alias: 'getListOptions',
@@ -35,18 +63,7 @@ describe('Multi connected autocomplete', () => {
35
63
  });
36
64
 
37
65
  cy.mount({
38
- Component: (
39
- <TestQueryProvider>
40
- <div style={{ paddingTop: 20 }}>
41
- <MultiConnectedAutocompleteField
42
- field="host.name"
43
- getEndpoint={getEndpoint}
44
- label={label}
45
- placeholder={placeholder}
46
- />
47
- </div>
48
- </TestQueryProvider>
49
- )
66
+ Component: <Component />
50
67
  });
51
68
  });
52
69
 
@@ -98,7 +115,7 @@ describe('Multi connected autocomplete', () => {
98
115
  cy.waitForRequest('@getSearchedOption');
99
116
 
100
117
  cy.fixture('inputField/searchedOption').then(() => {
101
- cy.get('@listOptions').find('li').should('have.length', 5);
118
+ cy.get('@listOptions').find('li').should('have.length', 6);
102
119
  });
103
120
 
104
121
  cy.get('[type="checkbox"]').eq(0).check();
@@ -115,7 +132,7 @@ describe('Multi connected autocomplete', () => {
115
132
  cy.fixture('inputField/listOptions').then((optionsData) => {
116
133
  cy.get('@listOptions')
117
134
  .find('li')
118
- .should('have.length', optionsData.result.length);
135
+ .should('have.length', optionsData.result.length + 1);
119
136
 
120
137
  cy.get('@listOptions').within(() => {
121
138
  optionsData.result.forEach((option) => {
@@ -124,4 +141,41 @@ describe('Multi connected autocomplete', () => {
124
141
  });
125
142
  });
126
143
  });
144
+
145
+ it('checks all options when Select all button is clicked', () => {
146
+ cy.get('[data-testid="Multi Connected Autocomplete"]').as('input');
147
+
148
+ cy.get('@input').click();
149
+
150
+ cy.contains('5 element(s) found');
151
+
152
+ cy.waitForRequest('@getListOptions');
153
+
154
+ cy.contains(labelSelectAll).click();
155
+
156
+ cy.contains(labelUnSelectAll).should('be.visible');
157
+
158
+ cy.get('[data-testid="CancelIcon"]').should('have.length', 5);
159
+
160
+ cy.makeSnapshot('checks all options when Select all button is clicked');
161
+ });
162
+
163
+ it('unchecks all options when unSelect all button is clicked', () => {
164
+ cy.get('[data-testid="Multi Connected Autocomplete"]').as('input');
165
+
166
+ cy.get('@input').click();
167
+
168
+ cy.contains('5 element(s) found');
169
+
170
+ cy.waitForRequest('@getListOptions');
171
+
172
+ cy.contains(labelSelectAll).click();
173
+ cy.contains(labelUnSelectAll).click();
174
+
175
+ cy.contains(labelSelectAll).should('be.visible');
176
+
177
+ cy.get('[data-testid="CancelIcon"]').should('have.length', 0);
178
+
179
+ cy.makeSnapshot('unchecks all options when unSelect all button is clicked');
180
+ });
127
181
  });
@@ -88,7 +88,7 @@ const ConnectedAutocompleteField = (
88
88
 
89
89
  const theme = useTheme();
90
90
 
91
- const { fetchQuery, isFetching, prefetchNextPage } = useFetchQuery<
91
+ const { fetchQuery, isFetching, prefetchNextPage, data } = useFetchQuery<
92
92
  ListingModel<TData>
93
93
  >({
94
94
  baseEndpoint,
@@ -322,6 +322,7 @@ const ConnectedAutocompleteField = (
322
322
 
323
323
  return (
324
324
  <AutocompleteField
325
+ total={data?.meta.total}
325
326
  filterOptions={(opt): SelectEntry => opt}
326
327
  loading={isFetching}
327
328
  options={
@@ -0,0 +1,78 @@
1
+ import { ListSubheader, Typography } from '@mui/material';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Button } from '../../../../components/Button';
4
+ import { useListboxStyles } from './Multi.styles';
5
+
6
+ import {
7
+ labelElementsFound,
8
+ labelSelectAll,
9
+ labelUnSelectAll
10
+ } from '../../../translatedLabels';
11
+
12
+ const CustomListbox = ({
13
+ children,
14
+ label,
15
+ labelTotal,
16
+ handleSelectAllToggle,
17
+ ...props
18
+ }) => {
19
+ const { classes } = useListboxStyles();
20
+
21
+ return (
22
+ <ul {...props}>
23
+ <ListSubheader sx={{ padding: 0 }}>
24
+ <div className={classes.lisSubHeader}>
25
+ <Typography variant="body2">{labelTotal}</Typography>
26
+ <Button variant="ghost" size="small" onClick={handleSelectAllToggle}>
27
+ {label}
28
+ </Button>
29
+ </div>
30
+ </ListSubheader>
31
+ <div className={classes.dropdown}>{children}</div>
32
+ </ul>
33
+ );
34
+ };
35
+
36
+ const ListboxComponent = ({
37
+ disableSelectAll,
38
+ options,
39
+ isOptionSelected,
40
+ onChange,
41
+ total
42
+ }) => {
43
+ if (disableSelectAll) {
44
+ return;
45
+ }
46
+
47
+ return (listboxProps): JSX.Element | undefined => {
48
+ const { t } = useTranslation();
49
+
50
+ const allSelected =
51
+ options.length > 0 && options.every((opt) => isOptionSelected(opt));
52
+
53
+ const handleSelectAllToggle = (): void => {
54
+ const syntheticEvent = {} as React.SyntheticEvent;
55
+
56
+ if (allSelected) {
57
+ onChange?.(syntheticEvent, [], 'selectOption');
58
+
59
+ return;
60
+ }
61
+
62
+ onChange?.(syntheticEvent, options, 'selectOption');
63
+ };
64
+
65
+ return (
66
+ <CustomListbox
67
+ {...listboxProps}
68
+ label={t(allSelected ? labelUnSelectAll : labelSelectAll)}
69
+ handleSelectAllToggle={handleSelectAllToggle}
70
+ labelTotal={t(labelElementsFound, {
71
+ total: total || options.length
72
+ })}
73
+ />
74
+ );
75
+ };
76
+ };
77
+
78
+ export default ListboxComponent;
@@ -0,0 +1,26 @@
1
+ import { makeStyles } from 'tss-react/mui';
2
+
3
+ export const useStyles = makeStyles()((theme) => ({
4
+ deleteIcon: {
5
+ height: theme.spacing(1.5),
6
+ width: theme.spacing(1.5)
7
+ },
8
+ tag: {
9
+ fontSize: theme.typography.caption.fontSize
10
+ }
11
+ }));
12
+
13
+ export const useListboxStyles = makeStyles()((theme) => ({
14
+ lisSubHeader: {
15
+ width: '100%',
16
+ background: theme.palette.background.default,
17
+ padding: theme.spacing(0.5, 1, 0.5, 1.5),
18
+ display: 'flex',
19
+ justifyContent: 'space-between',
20
+ alignItems: 'center'
21
+ },
22
+ dropdown: {
23
+ width: '100%',
24
+ background: theme.palette.background.paper
25
+ }
26
+ }));
@@ -0,0 +1,124 @@
1
+ import { compose, includes, map, prop, reject, sortBy, toLower } from 'ramda';
2
+ import { JSX } from 'react';
3
+
4
+ import { Chip, ChipProps, Tooltip } from '@mui/material';
5
+ import { UseAutocompleteProps } from '@mui/material/useAutocomplete';
6
+
7
+ import Autocomplete, { Props as AutocompleteProps } from '..';
8
+ import { SelectEntry } from '../..';
9
+ import Option from '../../Option';
10
+ import ListboxComponent from './Listbox';
11
+ import { useStyles } from './Multi.styles';
12
+
13
+ type Multiple = boolean;
14
+ type DisableClearable = boolean;
15
+ type FreeSolo = boolean;
16
+
17
+ export interface Props
18
+ extends Omit<AutocompleteProps, 'renderTags' | 'renderOption' | 'multiple'>,
19
+ Omit<
20
+ UseAutocompleteProps<SelectEntry, Multiple, DisableClearable, FreeSolo>,
21
+ 'multiple'
22
+ > {
23
+ chipProps?: ChipProps;
24
+ disableSortedOptions?: boolean;
25
+ disableSelectAll?: boolean;
26
+ getOptionTooltipLabel?: (option) => string;
27
+ getTagLabel?: (option) => string;
28
+ optionProperty?: string;
29
+ customRenderTags?: (tags: React.ReactNode) => React.ReactNode;
30
+ total?: number;
31
+ }
32
+
33
+ const MultiAutocompleteField = ({
34
+ value,
35
+ options,
36
+ disableSortedOptions = false,
37
+ disableSelectAll = true,
38
+ optionProperty = 'name',
39
+ getOptionLabel = (option): string => option.name,
40
+ getTagLabel = (option): string => option[optionProperty],
41
+ getOptionTooltipLabel,
42
+ chipProps,
43
+ customRenderTags,
44
+ onChange,
45
+ total,
46
+ ...props
47
+ }: Props): JSX.Element => {
48
+ const { classes } = useStyles();
49
+
50
+ const renderTags = (renderedValue, getTagProps): Array<JSX.Element> =>
51
+ renderedValue.map((option, index) => {
52
+ return (
53
+ <Tooltip
54
+ key={option.id}
55
+ placement="top"
56
+ title={getOptionTooltipLabel?.(option)}
57
+ >
58
+ <Chip
59
+ classes={{
60
+ deleteIcon: classes.deleteIcon,
61
+ root: classes.tag
62
+ }}
63
+ data-testid={`tag-option-chip-${option.id}`}
64
+ label={getTagLabel(option)}
65
+ size="medium"
66
+ {...getTagProps({ index })}
67
+ {...chipProps}
68
+ onDelete={(event) => chipProps?.onDelete?.(event, option)}
69
+ />
70
+ </Tooltip>
71
+ );
72
+ });
73
+
74
+ const getLimitTagsText = (more): JSX.Element => <Option>{`+${more}`}</Option>;
75
+
76
+ const values = (value as Array<SelectEntry>) || [];
77
+
78
+ const isOptionSelected = ({ id }): boolean => {
79
+ const valueIds = map(prop('id'), values);
80
+
81
+ return includes(id, valueIds);
82
+ };
83
+
84
+ const sortByName = sortBy(compose(toLower, prop(optionProperty)));
85
+
86
+ const autocompleteOptions = disableSortedOptions
87
+ ? options
88
+ : sortByName([...values, ...reject(isOptionSelected, options)]);
89
+
90
+ return (
91
+ <Autocomplete
92
+ disableCloseOnSelect
93
+ displayOptionThumbnail
94
+ multiple
95
+ getLimitTagsText={getLimitTagsText}
96
+ options={autocompleteOptions}
97
+ renderOption={(renderProps, option, { selected }): JSX.Element => (
98
+ <li
99
+ key={option.id}
100
+ {...(renderProps as React.HTMLAttributes<HTMLLIElement>)}
101
+ >
102
+ <Option checkboxSelected={selected}>{getOptionLabel(option)}</Option>
103
+ </li>
104
+ )}
105
+ value={values}
106
+ renderTags={(renderedValue, getTagProps): React.ReactNode =>
107
+ customRenderTags
108
+ ? customRenderTags(renderTags(renderedValue, getTagProps))
109
+ : renderTags(renderedValue, getTagProps)
110
+ }
111
+ ListboxComponent={ListboxComponent({
112
+ total,
113
+ onChange,
114
+ isOptionSelected,
115
+ disableSelectAll,
116
+ options
117
+ })}
118
+ onChange={onChange}
119
+ {...props}
120
+ />
121
+ );
122
+ };
123
+
124
+ export default MultiAutocompleteField;
@@ -1,119 +1,3 @@
1
- import { compose, includes, map, prop, reject, sortBy, toLower } from 'ramda';
2
- import { makeStyles } from 'tss-react/mui';
3
-
4
- import { Chip, ChipProps, Tooltip } from '@mui/material';
5
- import { UseAutocompleteProps } from '@mui/material/useAutocomplete';
6
-
7
- import Autocomplete, { Props as AutocompleteProps } from '..';
8
- import { SelectEntry } from '../..';
9
- import Option from '../../Option';
10
-
11
- const useStyles = makeStyles()((theme) => ({
12
- deleteIcon: {
13
- height: theme.spacing(1.5),
14
- width: theme.spacing(1.5)
15
- },
16
- tag: {
17
- fontSize: theme.typography.caption.fontSize
18
- }
19
- }));
20
-
21
- type Multiple = boolean;
22
- type DisableClearable = boolean;
23
- type FreeSolo = boolean;
24
-
25
- export interface Props
26
- extends Omit<AutocompleteProps, 'renderTags' | 'renderOption' | 'multiple'>,
27
- Omit<
28
- UseAutocompleteProps<SelectEntry, Multiple, DisableClearable, FreeSolo>,
29
- 'multiple'
30
- > {
31
- chipProps?: ChipProps;
32
- disableSortedOptions?: boolean;
33
- getOptionTooltipLabel?: (option) => string;
34
- getTagLabel?: (option) => string;
35
- optionProperty?: string;
36
- customRenderTags?: (tags: React.ReactNode) => React.ReactNode;
37
- }
38
-
39
- const MultiAutocompleteField = ({
40
- value,
41
- options,
42
- disableSortedOptions = false,
43
- optionProperty = 'name',
44
- getOptionLabel = (option): string => option.name,
45
- getTagLabel = (option): string => option[optionProperty],
46
- getOptionTooltipLabel,
47
- chipProps,
48
- customRenderTags,
49
- ...props
50
- }: Props): JSX.Element => {
51
- const { classes } = useStyles();
52
-
53
- const renderTags = (renderedValue, getTagProps): Array<JSX.Element> =>
54
- renderedValue.map((option, index) => {
55
- return (
56
- <Tooltip
57
- key={option.id}
58
- placement="top"
59
- title={getOptionTooltipLabel?.(option)}
60
- >
61
- <Chip
62
- classes={{
63
- deleteIcon: classes.deleteIcon,
64
- root: classes.tag
65
- }}
66
- data-testid={`tag-option-chip-${option.id}`}
67
- label={getTagLabel(option)}
68
- size="medium"
69
- {...getTagProps({ index })}
70
- {...chipProps}
71
- onDelete={(event) => chipProps?.onDelete?.(event, option)}
72
- />
73
- </Tooltip>
74
- );
75
- });
76
-
77
- const getLimitTagsText = (more): JSX.Element => <Option>{`+${more}`}</Option>;
78
-
79
- const values = (value as Array<SelectEntry>) || [];
80
-
81
- const isOptionSelected = ({ id }): boolean => {
82
- const valueIds = map(prop('id'), values);
83
-
84
- return includes(id, valueIds);
85
- };
86
-
87
- const sortByName = sortBy(compose(toLower, prop(optionProperty)));
88
-
89
- const autocompleteOptions = disableSortedOptions
90
- ? options
91
- : sortByName([...values, ...reject(isOptionSelected, options)]);
92
-
93
- return (
94
- <Autocomplete
95
- disableCloseOnSelect
96
- displayOptionThumbnail
97
- multiple
98
- getLimitTagsText={getLimitTagsText}
99
- options={autocompleteOptions}
100
- renderOption={(renderProps, option, { selected }): JSX.Element => (
101
- <li
102
- key={option.id}
103
- {...(renderProps as React.HTMLAttributes<HTMLLIElement>)}
104
- >
105
- <Option checkboxSelected={selected}>{getOptionLabel(option)}</Option>
106
- </li>
107
- )}
108
- renderTags={(renderedValue, getTagProps): React.ReactNode =>
109
- customRenderTags
110
- ? customRenderTags(renderTags(renderedValue, getTagProps))
111
- : renderTags(renderedValue, getTagProps)
112
- }
113
- value={value}
114
- {...props}
115
- />
116
- );
117
- };
1
+ import MultiAutocompleteField from './Multi';
118
2
 
119
3
  export default MultiAutocompleteField;
@@ -1,3 +1,7 @@
1
1
  export const searchLabel = 'Search';
2
2
  export const labelOpen = 'Open';
3
3
  export const labelClear = 'Clear';
4
+
5
+ export const labelSelectAll = 'Select all';
6
+ export const labelUnSelectAll = 'Unselect all';
7
+ export const labelElementsFound = '{{total}} element(s) found';
@@ -0,0 +1,137 @@
1
+ import { labelNextPage, labelPreviousPage } from '../Listing/translatedLabels';
2
+ import TestQueryProvider from '../api/TestQueryProvider';
3
+ import { Method } from '../api/useMutationQuery';
4
+ import Pagination from './Pagination';
5
+ import { generateItems } from './utils';
6
+
7
+ const defaultTotalItems = 25;
8
+ const itemsPerPage = 6;
9
+ const totalPages = Math.ceil(defaultTotalItems / itemsPerPage);
10
+
11
+ const initialize = ({
12
+ total = defaultTotalItems,
13
+ currentPage = 1
14
+ }: { total?: number; currentPage?: number }) => {
15
+ cy.interceptAPIRequest({
16
+ alias: 'list',
17
+ method: Method.GET,
18
+ path: '**/listing**',
19
+ response: {
20
+ result: generateItems(itemsPerPage),
21
+ meta: {
22
+ page: currentPage,
23
+ total,
24
+ limit: itemsPerPage
25
+ }
26
+ }
27
+ });
28
+
29
+ cy.mount({
30
+ Component: (
31
+ <div
32
+ style={{
33
+ width: '100%',
34
+ height: '100vh',
35
+ display: 'flex',
36
+ justifyContent: 'center',
37
+ alignItems: 'center'
38
+ }}
39
+ >
40
+ <div
41
+ style={{
42
+ height: '176px',
43
+ boxShadow: '2px 2px 4px rgba(0, 0, 0, 0.2)'
44
+ }}
45
+ >
46
+ <TestQueryProvider>
47
+ <Pagination
48
+ api={{ baseEndpoint: '/test/listing', queryKey: ['test'] }}
49
+ />
50
+ </TestQueryProvider>
51
+ </div>
52
+ </div>
53
+ )
54
+ });
55
+ };
56
+
57
+ describe('Pagination Component', () => {
58
+ it('render with correct initial state', () => {
59
+ initialize({});
60
+ cy.waitForRequest('@list');
61
+
62
+ cy.findByTestId(labelPreviousPage).should('be.disabled');
63
+
64
+ cy.findByTestId(labelNextPage).should('not.be.disabled');
65
+
66
+ cy.contains(`Page 1/${totalPages}`);
67
+
68
+ cy.makeSnapshot();
69
+ });
70
+
71
+ it('hides pagination controls when only one page exists', () => {
72
+ initialize({ total: itemsPerPage });
73
+ cy.waitForRequest('@list');
74
+
75
+ cy.findByTestId(labelPreviousPage).should('not.exist');
76
+ cy.findByTestId(labelNextPage).should('not.exist');
77
+ cy.contains(/Page \d+\/\d+/).should('not.exist');
78
+
79
+ cy.makeSnapshot();
80
+ });
81
+
82
+ it('navigates forward through pages correctly', () => {
83
+ initialize({});
84
+ cy.waitForRequest('@list');
85
+
86
+ cy.contains(`Page 1/${totalPages}`);
87
+
88
+ Array.from({ length: totalPages - 1 }).forEach((_, index) => {
89
+ cy.findByTestId(labelNextPage).click();
90
+ cy.waitForRequest('@list');
91
+
92
+ cy.contains(`Page ${index + 2}/${totalPages}`);
93
+ });
94
+
95
+ cy.findByTestId(labelNextPage).should('be.disabled');
96
+
97
+ cy.makeSnapshot();
98
+ });
99
+
100
+ it('navigates backward through pages correctly', () => {
101
+ initialize({});
102
+
103
+ cy.waitForRequest('@list');
104
+
105
+ Array.from({ length: totalPages - 1 }).forEach(() => {
106
+ cy.findByTestId(labelNextPage).click();
107
+
108
+ cy.waitForRequest('@list');
109
+ });
110
+
111
+ Array.from({ length: totalPages - 1 }).forEach((_, index) => {
112
+ cy.findByTestId(labelPreviousPage).click();
113
+ cy.waitForRequest('@list');
114
+
115
+ cy.contains(`Page ${totalPages - index - 1}/${totalPages}`);
116
+ });
117
+
118
+ cy.findByTestId(labelPreviousPage).should('be.disabled');
119
+
120
+ cy.makeSnapshot();
121
+ });
122
+
123
+ it('enables both buttons when on middle page', () => {
124
+ initialize({});
125
+
126
+ cy.waitForRequest('@list');
127
+
128
+ cy.findByTestId(labelNextPage).click();
129
+
130
+ cy.waitForRequest('@list');
131
+
132
+ cy.findByTestId(labelPreviousPage).should('not.be.disabled');
133
+ cy.findByTestId(labelNextPage).should('not.be.disabled');
134
+
135
+ cy.makeSnapshot();
136
+ });
137
+ });
@@ -0,0 +1,46 @@
1
+ import { Meta, StoryObj } from '@storybook/react';
2
+ import { http, HttpResponse } from 'msw';
3
+ import Pagination from '.';
4
+ import { generateItems } from './utils';
5
+
6
+ const mockedListing = {
7
+ result: generateItems(6),
8
+ meta: {
9
+ page: 1,
10
+ total: 35,
11
+ limit: 6
12
+ }
13
+ };
14
+
15
+ const meta: Meta<typeof Pagination> = {
16
+ args: {},
17
+ component: Pagination,
18
+ parameters: {
19
+ msw: {
20
+ handlers: [
21
+ http.get('**/listing**', () => {
22
+ return HttpResponse.json(mockedListing);
23
+ })
24
+ ]
25
+ }
26
+ },
27
+ render: (args) => {
28
+ return (
29
+ <div
30
+ style={{
31
+ width: '240px',
32
+ background: '#EDEDED'
33
+ }}
34
+ >
35
+ <Pagination {...args} />
36
+ </div>
37
+ );
38
+ }
39
+ };
40
+
41
+ export default meta;
42
+ type Story = StoryObj<typeof Pagination>;
43
+
44
+ export const Default: Story = {
45
+ args: { api: { baseEndpoint: '/test/listing', queryKey: ['pagination'] } }
46
+ };
@@ -0,0 +1,56 @@
1
+ import { makeStyles } from 'tss-react/mui';
2
+
3
+ export const useStyles = makeStyles()((theme) => ({
4
+ container: {
5
+ height: theme.spacing(22),
6
+ width: theme.spacing(30),
7
+ padding: theme.spacing(1),
8
+ display: 'flex',
9
+ flexDirection: 'column',
10
+ justifyContent: 'space-between',
11
+ alignItems: 'center',
12
+ gap: theme.spacing(2)
13
+ },
14
+ notFound: {
15
+ height: theme.spacing(10),
16
+ width: theme.spacing(30),
17
+ padding: theme.spacing(1)
18
+ },
19
+ body: {
20
+ width: '100%',
21
+ height: '100%',
22
+ display: 'flex',
23
+ justifyContent: 'space-between',
24
+ gap: theme.spacing(1)
25
+ },
26
+ content: {
27
+ width: '100%',
28
+ display: 'flex',
29
+ flexDirection: 'column',
30
+ gap: theme.spacing(0.5)
31
+ },
32
+ page: {
33
+ fontWeight: theme.typography.fontWeightMedium
34
+ },
35
+ arrowContainer: {
36
+ display: 'flex',
37
+ justifyContent: 'space-between',
38
+ alignItems: 'center'
39
+ },
40
+ icon: {
41
+ color: theme.palette.text.primary
42
+ },
43
+ arrow: {
44
+ fontSize: theme.spacing(2)
45
+ },
46
+ item: {
47
+ color: 'inherit',
48
+ textDecoration: 'none'
49
+ },
50
+ link: {
51
+ '&:hover': {
52
+ cursor: 'pointer',
53
+ color: theme.palette.primary.main
54
+ }
55
+ }
56
+ }));
@@ -0,0 +1,146 @@
1
+ import { CircularProgress, Link, Typography } from '@mui/material';
2
+ import { equals, isEmpty, isNil } from 'ramda';
3
+ import { useMemo, useState } from 'react';
4
+
5
+ import ArrowBackIcon from '@mui/icons-material/ArrowBackIosNew';
6
+ import ArrowForwardIcon from '@mui/icons-material/ArrowForwardIos';
7
+ import { useTranslation } from 'react-i18next';
8
+ import IconButton from '../Button/Icon';
9
+ import {
10
+ labelNextPage,
11
+ labelNoResultFound,
12
+ labelPreviousPage
13
+ } from '../Listing/translatedLabels';
14
+ import buildListingEndpoint from '../api/buildListingEndpoint';
15
+ import { Listing } from '../api/models';
16
+ import useFetchQuery from '../api/useFetchQuery';
17
+ import { truncate } from '../utils';
18
+ import { useStyles } from './Pagination.styles';
19
+
20
+ interface Props {
21
+ api: {
22
+ baseEndpoint: string;
23
+ queryKey: Array<string>;
24
+ searchConditions?;
25
+ };
26
+ labelHasNoElements?: string;
27
+ onItemClick?: ({ id }: { id: number }) => void;
28
+ }
29
+
30
+ const limit = 6;
31
+
32
+ const Pagination = ({
33
+ api: { baseEndpoint, queryKey, searchConditions },
34
+ labelHasNoElements = labelNoResultFound,
35
+ onItemClick
36
+ }: Props) => {
37
+ const { t } = useTranslation();
38
+ const { cx, classes } = useStyles();
39
+ const [page, setPage] = useState(1);
40
+
41
+ const { data, isLoading } = useFetchQuery<
42
+ Listing<{ id: number; name: string }>
43
+ >({
44
+ getEndpoint: (parameters): string =>
45
+ buildListingEndpoint({
46
+ baseEndpoint: baseEndpoint,
47
+ parameters: {
48
+ ...parameters,
49
+ page,
50
+ limit,
51
+ ...(searchConditions
52
+ ? {
53
+ search: {
54
+ conditions: searchConditions
55
+ }
56
+ }
57
+ : {}),
58
+ sort: { status: 'DESC' }
59
+ }
60
+ }),
61
+ getQueryKey: () => [...queryKey, page],
62
+ queryOptions: {
63
+ suspense: false
64
+ }
65
+ });
66
+
67
+ const pagesCount = Math.ceil(data?.meta.total / limit);
68
+ const arePaginationComponentsDisplayed = !equals(pagesCount, 1);
69
+
70
+ const hasNoElements = useMemo(
71
+ () => isEmpty(data?.result) || isNil(data?.result),
72
+ [data]
73
+ );
74
+
75
+ if (hasNoElements) {
76
+ return (
77
+ <div className={classes.notFound}>
78
+ <Typography color="disabled">{t(labelHasNoElements)}</Typography>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ return (
84
+ <div className={classes.container}>
85
+ <div className={classes.body}>
86
+ {arePaginationComponentsDisplayed && (
87
+ <div className={classes.arrowContainer}>
88
+ <IconButton
89
+ onClick={() => setPage(page - 1)}
90
+ disabled={equals(page, 1)}
91
+ dataTestid={labelPreviousPage}
92
+ className={classes.icon}
93
+ >
94
+ <ArrowBackIcon className={classes.arrow} />
95
+ </IconButton>
96
+ </div>
97
+ )}
98
+
99
+ <div className={classes.content}>
100
+ {isLoading ? (
101
+ <CircularProgress color="inherit" size={25} />
102
+ ) : (
103
+ data?.result.map(({ id, name }) => (
104
+ <Link
105
+ key={id}
106
+ variant="body2"
107
+ className={cx({
108
+ [classes.item]: true,
109
+ [classes.link]: !!onItemClick
110
+ })}
111
+ onClick={() => onItemClick?.({ id })}
112
+ >
113
+ {truncate({
114
+ content: name,
115
+ maxLength: 25
116
+ })}
117
+ </Link>
118
+ ))
119
+ )}
120
+ </div>
121
+
122
+ {arePaginationComponentsDisplayed && (
123
+ <div className={classes.arrowContainer}>
124
+ <IconButton
125
+ onClick={() => setPage(page + 1)}
126
+ disabled={equals(pagesCount, page)}
127
+ className={classes.icon}
128
+ dataTestid={labelNextPage}
129
+ >
130
+ <ArrowForwardIcon className={classes.arrow} />
131
+ </IconButton>
132
+ </div>
133
+ )}
134
+ </div>
135
+
136
+ {arePaginationComponentsDisplayed && (
137
+ <Typography
138
+ className={classes.page}
139
+ variant="body2"
140
+ >{`Page ${page}/${pagesCount}`}</Typography>
141
+ )}
142
+ </div>
143
+ );
144
+ };
145
+
146
+ export default Pagination;
@@ -0,0 +1,3 @@
1
+ import Pagination from './Pagination';
2
+
3
+ export default Pagination;
@@ -0,0 +1,7 @@
1
+ export const generateItems = (count: number) =>
2
+ Array(count)
3
+ .fill(0)
4
+ .map((_, idx) => ({
5
+ id: idx,
6
+ name: `Item Item Item ${idx}`
7
+ }));
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Props as SingleAutocompleteFieldProps } from './InputField/Select/Autocomplete';
2
2
 
3
3
  export { default as IconButton } from './Button/Icon';
4
+ export { default as Pagination } from './Pagination';
4
5
 
5
6
  export { Checkbox, CheckboxGroup } from './Checkbox';
6
7
 
@@ -1,4 +1,4 @@
1
- import { fromPairs, startsWith } from 'ramda';
1
+ import { equals, fromPairs, startsWith } from 'ramda';
2
2
 
3
3
  import { QueryParameter } from '../models';
4
4
 
@@ -25,7 +25,12 @@ const getUrlQueryParameters = <
25
25
 
26
26
  const entries = [...urlParams.entries()].map<[string, string]>(
27
27
  ([key, value]) => {
28
- if (startsWith('/', value)) {
28
+ if (
29
+ startsWith('/', value) ||
30
+ (!equals('false', value) &&
31
+ !equals('true', value) &&
32
+ !equals(value.match(/^[a-zA-Z]/), null))
33
+ ) {
29
34
  return [key, value];
30
35
  }
31
36