Skip to content

Commit d5de7e0

Browse files
authored
Merge branch 'main' into fix/unicweb-147
2 parents 70f8299 + 7caeecf commit d5de7e0

File tree

32 files changed

+1459
-12
lines changed

32 files changed

+1459
-12
lines changed

Diff for: package-lock.json

+9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: package.json

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"axios": "^1.8.2",
3131
"classnames": "^2.5.1",
3232
"graphql": "^16.10.0",
33+
"jwt-decode": "^4.0.0",
3334
"lodash": "^4.17.21",
3435
"next": "14.2.25",
3536
"next-auth": "^4.24.11",

Diff for: src/app/api/auth/useAuth.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ describe('useAuth hook', () => {
9292

9393
renderHook(() => useAuth());
9494

95-
expect(mockDispatch).toHaveBeenCalledWith(fetchUser());
95+
expect(mockDispatch).toHaveBeenCalledWith(fetchUser({}));
9696
});
9797

9898
it('redirects to login if not authenticated and not on login page', () => {

Diff for: src/app/api/auth/useAuth.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,18 @@ export const useAuth = () => {
4747
/** logout if session error */
4848
useEffect(() => {
4949
if (sessionError) {
50-
console.debug('useEffect handleLogout Session error', sessionError);
50+
console.debug('[useAuth] handleLogout session error', sessionError);
5151
handleLogout();
5252
}
5353
}, [handleLogout, sessionError]);
5454

5555
/** fetch user after authenticated */
5656
useEffect(() => {
5757
if (isAuthenticated) {
58-
// @ts-expect-error type UnknownAction
59-
dispatch(fetchUser());
58+
// @ts-expect-error - UnknownAction
59+
dispatch(fetchUser({ errCallback: handleLogout }));
6060
}
61-
}, [isAuthenticated, dispatch]);
61+
}, [isAuthenticated, dispatch, handleLogout]);
6262

6363
const _user = {
6464
...session?.user,

Diff for: src/app/profile/settings/page.module.css

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
.profileSettingsWrapper {
2+
width: 100%;
3+
padding: 16px 24px;
4+
}
5+
6+
.cardsWrapper {
7+
width: 100%;
8+
}
9+
10+
.profileSettings {
11+
max-width: 1000px;
12+
margin: auto;
13+
display: flex;
14+
width: 100%;
15+
}
16+
17+
.profileSettings .profileSettingsHeader {
18+
display: flex;
19+
flex-direction: row;
20+
justify-content: space-between;
21+
align-items: center;
22+
}

Diff for: src/app/profile/settings/page.test.tsx

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
3+
import ProfileSettingsPage from '@/app/profile/settings/page';
4+
import { useAxios } from '@/lib/axios';
5+
6+
jest.mock('@/components/ProfilePage/Cards/DeleteCard', () => jest.fn(() => <div>Delete Card</div>));
7+
jest.mock('@/components/ProfilePage/Cards/FunctionCard', () => jest.fn(() => <div>Function Card</div>));
8+
jest.mock('@/components/ProfilePage/Cards/IdentificationCard', () => jest.fn(() => <div>Identification Card</div>));
9+
jest.mock('@/components/ProfilePage/Cards/ResearchDomainCard', () => jest.fn(() => <div>Research Domain Card</div>));
10+
11+
jest.mock('react-intl-universal', () => ({
12+
get: jest.fn((key) => key),
13+
}));
14+
jest.mock('@/lib/axios', () => ({
15+
useAxios: jest.fn(),
16+
}));
17+
jest.mock('@/store/global', () => ({
18+
useLang: jest.fn(() => 'EN'),
19+
}));
20+
21+
describe('ProfileSettingsPage', () => {
22+
it('renders ProfileSettingsPage and child components correctly', async () => {
23+
// Mock API response
24+
(useAxios as jest.Mock).mockReturnValueOnce({
25+
data: {
26+
roleOptions: [{ value: 'admin', label: 'Admin' }],
27+
researchDomainOptions: [{ value: 'AI', label: 'AI' }],
28+
},
29+
});
30+
31+
render(<ProfileSettingsPage />);
32+
33+
// Wait for API data to be processed
34+
await waitFor(() => expect(useAxios).toHaveBeenCalledTimes(1));
35+
36+
// Check if ProfileSettings and mock child components render
37+
expect(screen.getByText('screen.profileSettings.title')).toBeInTheDocument();
38+
expect(screen.getByText('Identification Card')).toBeInTheDocument();
39+
expect(screen.getByText('Function Card')).toBeInTheDocument();
40+
expect(screen.getByText('Research Domain Card')).toBeInTheDocument();
41+
expect(screen.getByText('Delete Card')).toBeInTheDocument();
42+
});
43+
});

Diff for: src/app/profile/settings/page.tsx

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
'use client';
2+
import { Space, Typography } from 'antd';
3+
import intl from 'react-intl-universal';
4+
5+
import DeleteCard from '@/components/ProfilePage/Cards/DeleteCard';
6+
import FunctionCard from '@/components/ProfilePage/Cards/FunctionCard';
7+
import IdentificationCard from '@/components/ProfilePage/Cards/IdentificationCard';
8+
import ResearchDomainCard from '@/components/ProfilePage/Cards/ResearchDomainCard';
9+
import config from '@/config';
10+
import { useAxios } from '@/lib/axios';
11+
import { useLang } from '@/store/global';
12+
13+
import styles from './page.module.css';
14+
const { Title } = Typography;
15+
16+
const ProfileSettings = () => {
17+
useLang();
18+
const result = useAxios(`${config.USERS_API_URL}/userOptions`);
19+
const { roleOptions = [], researchDomainOptions = [] } = result?.data || {};
20+
21+
return (
22+
<div className={styles.profileSettingsWrapper}>
23+
<Space size={16} direction='vertical' className={styles.profileSettings}>
24+
<div className={styles.profileSettingsHeader}>
25+
<Title level={4}>{intl.get('screen.profileSettings.title')}</Title>
26+
{/*For community and member page*/}
27+
{/*<Link href={`/member/${userInfo?.keycloak_id}`}>*/}
28+
{/* <Button type='primary'>{intl.get('screen.profileSettings.viewProfile')}</Button>*/}
29+
{/*</Link>*/}
30+
</div>
31+
<Space size={24} direction='vertical' className={styles.cardsWrapper}>
32+
<IdentificationCard />
33+
<FunctionCard roleOptions={roleOptions} />
34+
<ResearchDomainCard researchDomainOptions={researchDomainOptions} />
35+
<DeleteCard />
36+
</Space>
37+
</Space>
38+
</div>
39+
);
40+
};
41+
42+
export default ProfileSettings;

Diff for: src/components/CatalogTables/ResourcesTable/ResourcesTable.test.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ jest.mock('@/store/user/thunks', () => ({
2424
updateUserConfig: jest.fn(),
2525
}));
2626
jest.mock('@/lib/hooks/useHash', () => jest.fn());
27+
jest.mock('@/store/user', () => ({
28+
useUser: () => jest.fn(),
29+
}));
2730
jest.mock('query-string', () => ({
2831
parse: jest.fn(),
2932
}));

Diff for: src/components/CatalogTables/TablesTable/TablesTable.test.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ jest.mock('react-redux', () => ({
2121
jest.mock('@/store/user/thunks', () => ({
2222
updateUserConfig: jest.fn(),
2323
}));
24+
jest.mock('@/store/user', () => ({
25+
useUser: () => jest.fn(),
26+
}));
2427
jest.mock('@/lib/hooks/useHash');
2528
jest.mock('query-string', () => ({
2629
parse: jest.fn(),

Diff for: src/components/CatalogTables/VariablesTable/VariablesTable.test.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ jest.mock('react-redux', () => ({
2121
jest.mock('@/store/user/thunks', () => ({
2222
updateUserConfig: jest.fn(),
2323
}));
24+
jest.mock('@/store/user', () => ({
25+
useUser: () => jest.fn(),
26+
}));
2427
jest.mock('@/lib/hooks/useHash');
2528
jest.mock('query-string', () => ({
2629
parse: jest.fn(),

Diff for: src/components/DataRelease/DataRelease.test.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jest.mock('react-redux', () => ({
1616
jest.mock('@/store/global/thunks', () => ({
1717
fetchStats: jest.fn(),
1818
}));
19+
jest.mock('@/store/user', () => jest.fn());
1920
jest.mock('react-intl-universal', () => ({
2021
get: jest.fn((key) => key), // Mock translations to return key names
2122
}));

Diff for: src/components/Header/index.tsx

+12-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { DownOutlined, LogoutOutlined, ReadOutlined } from '@ant-design/icons';
1+
import { DownOutlined, LogoutOutlined, ReadOutlined, UserOutlined } from '@ant-design/icons';
22
import UserAvatar from '@ferlab/ui/core/components/UserAvatar';
33
import { Button, Dropdown, MenuProps, PageHeader, Space } from 'antd';
44
import Image from 'next/image';
@@ -55,6 +55,17 @@ const Header = () => {
5555
{
5656
type: 'divider',
5757
},
58+
{
59+
key: 'profile_settings',
60+
label: (
61+
<Link href={'/profile/settings'}>
62+
<Space>
63+
<UserOutlined />
64+
{intl.get('layout.user.menu.settings')}
65+
</Space>
66+
</Link>
67+
),
68+
},
5869
{
5970
key: 'logout',
6071
label: intl.get('layout.user.menu.logout'),

Diff for: src/components/ProfilePage/Cards/BaseCard.tsx

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import GridCard from '@ferlab/ui/core/view/v2/GridCard';
2+
import { Button, FormInstance, Space, Typography } from 'antd';
3+
import { PropsWithChildren } from 'react';
4+
import intl from 'react-intl-universal';
5+
6+
interface OwnProps {
7+
title: string;
8+
form: FormInstance;
9+
isValueChanged: boolean;
10+
onDiscardChanges: () => void;
11+
}
12+
13+
const { Title } = Typography;
14+
15+
const BaseCard = ({ title, isValueChanged, form, onDiscardChanges, children }: PropsWithChildren<OwnProps>) => (
16+
<GridCard
17+
title={<Title level={4}>{title}</Title>}
18+
footer={
19+
<Space>
20+
<Button type='primary' disabled={!isValueChanged} onClick={form.submit}>
21+
{intl.get('screen.profileSettings.cards.saveChanges')}
22+
</Button>
23+
{isValueChanged && (
24+
<Button type='text' onClick={onDiscardChanges}>
25+
{intl.get('screen.profileSettings.cards.discardChanges')}
26+
</Button>
27+
)}
28+
</Space>
29+
}
30+
content={children}
31+
/>
32+
);
33+
34+
export default BaseCard;

Diff for: src/components/ProfilePage/Cards/BaseForm.tsx

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Form, FormInstance } from 'antd';
2+
import { Store } from 'antd/lib/form/interface';
3+
import { PropsWithChildren } from 'react';
4+
import intl from 'react-intl-universal';
5+
6+
interface OwnProps<T> {
7+
form: FormInstance;
8+
initialValues: Store;
9+
hasChangedInitialValue: Record<any, boolean>;
10+
onHasChanged: (values: Record<any, boolean>) => void;
11+
onFinish: (values: T) => void;
12+
}
13+
14+
const BaseForm = <T,>({
15+
form,
16+
initialValues,
17+
onHasChanged,
18+
hasChangedInitialValue,
19+
children,
20+
onFinish,
21+
}: PropsWithChildren<OwnProps<T>>) => (
22+
<Form
23+
layout='vertical'
24+
form={form}
25+
initialValues={initialValues}
26+
onFieldsChange={(changedFields) => {
27+
const field = changedFields[0];
28+
onHasChanged({
29+
...hasChangedInitialValue,
30+
[field.name as any]:
31+
initialValues.current &&
32+
JSON.stringify(initialValues.current[field.name as any]) !== JSON.stringify(field.value),
33+
});
34+
}}
35+
onFinish={onFinish}
36+
validateMessages={{
37+
required: intl.get('global.forms.errors.requiredField'),
38+
types: {
39+
email: intl.get('global.forms.errors.enterValidEmail'),
40+
url: intl.get('global.forms.errors.enterValidUrl'),
41+
},
42+
}}
43+
>
44+
{children}
45+
</Form>
46+
);
47+
48+
export default BaseForm;

0 commit comments

Comments
 (0)