Skip to content

Commit 346896b

Browse files
enrimon15sebbalex
andauthored
feat(OI-497): form fields tooltip info (#887)
* feat: info tooltip for fields common component * test: FieldWithInfo test * feat: add info tooltip on requireSameIdp field * test: adapt dashboard tests with new info tooltip * chore(info tooltip): add todo for replacing change me text * Update src/oneid/oneid-control-panel/src/pages/Dashboard/Dashboard.tsx Co-authored-by: Alessandro Sebastiani <sebbalex@users.noreply.github.com> * chore(admin panel): lint fix * test(admin panel): fix same idp tooltip text * feat(admin panel): add possibility to use jsx to render info tooltip * feat(admin panel): add same idp info tooltip with styled link --------- Co-authored-by: Alessandro Sebastiani <sebbalex@users.noreply.github.com>
1 parent df9d1c7 commit 346896b

File tree

5 files changed

+263
-10
lines changed

5 files changed

+263
-10
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import FieldWithInfo from './FieldWithInfo';
3+
import { TextField, Select, MenuItem } from '@mui/material';
4+
5+
describe('FieldWithInfo', () => {
6+
const tooltipText = 'This is a tooltip';
7+
8+
it('should render with a TextField as a child and inputAdornment as true', () => {
9+
render(
10+
<FieldWithInfo tooltipText={tooltipText} inputAdornment>
11+
<TextField />
12+
</FieldWithInfo>
13+
);
14+
expect(screen.getByRole('textbox')).toBeInTheDocument();
15+
expect(screen.getByRole('button')).toBeInTheDocument();
16+
});
17+
18+
it('should render with a Select as a child and inputAdornment as true', () => {
19+
render(
20+
<FieldWithInfo tooltipText={tooltipText} inputAdornment>
21+
<Select value="">
22+
<MenuItem value="1">Option 1</MenuItem>
23+
</Select>
24+
</FieldWithInfo>
25+
);
26+
expect(screen.getByRole('combobox')).toBeInTheDocument();
27+
expect(screen.getByRole('button')).toBeInTheDocument();
28+
});
29+
30+
it('should render with a TextField as a child and inputAdornment as false', () => {
31+
render(
32+
<FieldWithInfo tooltipText={tooltipText}>
33+
<TextField />
34+
</FieldWithInfo>
35+
);
36+
expect(screen.getByRole('textbox')).toBeInTheDocument();
37+
expect(screen.getByRole('button')).toBeInTheDocument();
38+
});
39+
40+
it('should render with a simple div as a child', () => {
41+
render(
42+
<FieldWithInfo tooltipText={tooltipText}>
43+
<div>Hello</div>
44+
</FieldWithInfo>
45+
);
46+
expect(screen.getByText('Hello')).toBeInTheDocument();
47+
expect(screen.getByRole('button')).toBeInTheDocument();
48+
});
49+
50+
it('should show the tooltip on hover', async () => {
51+
render(
52+
<FieldWithInfo tooltipText={tooltipText}>
53+
<TextField />
54+
</FieldWithInfo>
55+
);
56+
const infoButton = screen.getByRole('button');
57+
fireEvent.mouseOver(infoButton);
58+
expect(await screen.findByText(tooltipText)).toBeInTheDocument();
59+
});
60+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { TextField, Select } from '@mui/material';
2+
import React, { ReactNode } from 'react';
3+
import { Box, IconButton, Tooltip } from '@mui/material';
4+
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
5+
6+
const infoIconButtonSx = {
7+
color: 'text.secondary',
8+
p: 0.5,
9+
'&:hover': { color: 'primary.main' },
10+
};
11+
12+
type Placement =
13+
| 'right'
14+
| 'left'
15+
| 'top'
16+
| 'bottom'
17+
| 'top-end'
18+
| 'bottom-end'
19+
| 'top-start'
20+
| 'bottom-start';
21+
22+
// Type guard for ReactElement valid element with known props (TextField and Select)
23+
function isReactElementWithProps<
24+
P = { InputProps?: object; endAdornment?: object },
25+
>(el: ReactNode): el is React.ReactElement<P> {
26+
return React.isValidElement(el);
27+
}
28+
29+
type FieldWithInfoProps = {
30+
children: ReactNode;
31+
tooltipText: string | ReactNode; // tooltipText can be either a string -> it will be displayed as plain text inside the tooltip; or a ReactNode -> it will be rendered directly as JSX/HTML
32+
placement?: Placement;
33+
inputAdornment?: boolean;
34+
};
35+
36+
const FieldWithInfo: React.FC<FieldWithInfoProps> = ({
37+
children,
38+
tooltipText,
39+
placement = 'top',
40+
inputAdornment = false, // for TextField or Select
41+
}) => {
42+
// tooltip or popover popup
43+
const infoPopup = (
44+
<Tooltip title={tooltipText} arrow placement={placement}>
45+
<span>
46+
<IconButton
47+
size="small"
48+
tabIndex={0}
49+
sx={infoIconButtonSx}
50+
data-testid="info-icon"
51+
>
52+
<InfoOutlinedIcon fontSize="small" />
53+
</IconButton>
54+
</span>
55+
</Tooltip>
56+
);
57+
58+
// if inputAdornment is true build info popup inside the input field (on the right)
59+
if (inputAdornment && isReactElementWithProps(children)) {
60+
const isTextField = children.type === TextField;
61+
const isSelect = children.type === Select;
62+
63+
// case 1 = children is a TextField
64+
if (isTextField) {
65+
const prevProps = children.props.InputProps ?? {};
66+
return React.cloneElement(children, {
67+
InputProps: {
68+
...prevProps,
69+
endAdornment: infoPopup,
70+
},
71+
});
72+
}
73+
74+
// case 2 = children is a Select
75+
if (isSelect) {
76+
const prevProps = children.props ?? {};
77+
return React.cloneElement(children, {
78+
...prevProps,
79+
endAdornment: (
80+
<>
81+
<Box sx={{ marginRight: 3 }}>{infoPopup}</Box>
82+
{children.props.endAdornment}
83+
</>
84+
),
85+
});
86+
}
87+
}
88+
89+
// if inputAdornment is false (or children type isn't one of TextField or Select)
90+
// build info popup outside the component, to the right
91+
return (
92+
<Box
93+
sx={{
94+
display: 'flex',
95+
alignItems: 'center',
96+
flexWrap: 'wrap',
97+
minWidth: 200,
98+
flex: 1,
99+
}}
100+
>
101+
{children}
102+
{infoPopup}
103+
</Box>
104+
);
105+
};
106+
107+
export default FieldWithInfo;

src/oneid/oneid-control-panel/src/pages/Dashboard/Dashboard.test.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { BrowserRouter } from 'react-router-dom';
1212
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
1313
import Layout from '../../components/Layout';
1414
import { ClientIdProvider, useClientId } from '../../context/ClientIdContext';
15+
import FieldWithInfo from '../../components/FieldWithInfo';
1516
import { useEffect } from 'react';
1617

1718
vi.mock('react-oidc-context', () => ({
@@ -296,4 +297,56 @@ describe('Dashboard UI', () => {
296297
expect(window.location.pathname).toBe('/');
297298
});
298299
});
300+
301+
it('toggles the "Required Same IDP" switch and shows tooltip', async () => {
302+
render(<Dashboard />, { wrapper: createWrapper() });
303+
304+
const switchControl = screen.getByLabelText(/Required Same IDP/i);
305+
expect(switchControl).not.toBeChecked();
306+
307+
fireEvent.click(switchControl);
308+
expect(switchControl).toBeChecked();
309+
310+
const infoButton = screen.getByTestId('info-icon');
311+
fireEvent.mouseOver(infoButton);
312+
expect(
313+
await screen.findByText(
314+
/Same IDP is a function that will return a custom request indicating whether the user has logged in using the same IDP as the previous time./i
315+
)
316+
).toBeInTheDocument();
317+
});
318+
319+
it('renders tooltip with ReactNode containing a link', async () => {
320+
const TestComponent = () => (
321+
<FieldWithInfo
322+
tooltipText={
323+
<span>
324+
For more info{' '}
325+
<a href="https://example.com/help" target="_blank" rel="noreferrer">
326+
click here
327+
</a>
328+
</span>
329+
}
330+
placement="top"
331+
>
332+
<div>Test field</div>
333+
</FieldWithInfo>
334+
);
335+
336+
render(<TestComponent />, { wrapper: createWrapper() });
337+
338+
const infoButton = screen.getByTestId('info-icon');
339+
expect(infoButton).toBeInTheDocument();
340+
341+
fireEvent.mouseOver(infoButton);
342+
343+
await waitFor(() => {
344+
expect(screen.getByText('For more info')).toBeInTheDocument();
345+
});
346+
347+
const link = screen.getByRole('link', { name: /click here/i });
348+
expect(link).toBeInTheDocument();
349+
expect(link).toHaveAttribute('href', 'https://example.com/help');
350+
expect(link).toHaveAttribute('target', '_blank');
351+
});
299352
});

src/oneid/oneid-control-panel/src/pages/Dashboard/Dashboard.tsx

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
DialogContent,
2222
DialogContentText,
2323
DialogActions,
24+
Link,
2425
} from '@mui/material';
2526
import {
2627
SpidLevel,
@@ -42,6 +43,8 @@ import SaveIcon from '@mui/icons-material/Save';
4243
import AddIcon from '@mui/icons-material/Add';
4344
import { PageContainer } from '../../components/PageContainer';
4445
import { ContentBox } from '../../components/ContentBox';
46+
import FieldWithInfo from '../../components/FieldWithInfo';
47+
import { tooltipLinkSx } from '../../utils/styles';
4548

4649
export const Dashboard = () => {
4750
const [formData, setFormData] =
@@ -374,17 +377,38 @@ export const Dashboard = () => {
374377
/>
375378

376379
<FormGroup sx={{ mt: 2, mb: 1 }}>
377-
<FormControlLabel
378-
control={
379-
<Switch
380-
sx={{ mr: 2, ml: 1 }}
381-
name="requiredSameIdp"
382-
checked={formData?.requiredSameIdp || false}
383-
onChange={handleChange('requiredSameIdp')}
384-
/>
380+
<FieldWithInfo
381+
tooltipText={
382+
<span>
383+
Same IDP is a function that will return a custom request
384+
indicating whether the user has logged in using the same IDP
385+
as the previous time.
386+
<br />
387+
More info can be found{' '}
388+
<Link
389+
href="https://pagopa.atlassian.net/wiki/spaces/OI/pages/1560477700/RFC+OI-004+Check+last+used+IDP+-+OTP"
390+
target="_blank"
391+
rel="noopener noreferrer"
392+
sx={tooltipLinkSx}
393+
>
394+
here
395+
</Link>
396+
</span>
385397
}
386-
label="Required Same IDP"
387-
/>
398+
placement="top"
399+
>
400+
<FormControlLabel
401+
control={
402+
<Switch
403+
sx={{ mr: 2, ml: 1 }}
404+
name="requiredSameIdp"
405+
checked={formData?.requiredSameIdp || false}
406+
onChange={handleChange('requiredSameIdp')}
407+
/>
408+
}
409+
label="Required Same IDP"
410+
/>
411+
</FieldWithInfo>
388412
</FormGroup>
389413
</ContentBox>
390414

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { SxProps, Theme } from '@mui/material';
2+
3+
export const tooltipLinkSx: SxProps<Theme> = {
4+
color: 'secondary.main',
5+
textDecoration: 'underline',
6+
'&:hover': {
7+
color: 'secondary.light',
8+
},
9+
};

0 commit comments

Comments
 (0)