Skip to content
Snippets Groups Projects
Commit eb6edcc7 authored by mateusz-winiarczyk's avatar mateusz-winiarczyk
Browse files

Merge branch 'MIN-191-add-possibility-to-login' into 'development'

feat(overlays): MIN-191 add possibility to login

See merge request !79
parents a185dfbb abda0046
No related branches found
No related tags found
2 merge requests!223reset the pin numbers before search results are fetch (so the results will be...,!79feat(overlays): MIN-191 add possibility to login
Pipeline #83590 passed
Showing
with 348 additions and 5 deletions
import { render, screen, fireEvent } from '@testing-library/react';
import { StoreType } from '@/redux/store';
import {
InitialStoreState,
getReduxWrapperWithStore,
} from '@/utils/testing/getReduxWrapperWithStore';
import { act } from 'react-dom/test-utils';
import { LoginModal } from './LoginModal.component';
const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => {
const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState);
return (
render(
<Wrapper>
<LoginModal />
</Wrapper>,
),
{
store,
}
);
};
test('renders LoginModal component', () => {
renderComponent();
const loginInput = screen.getByLabelText(/login/i);
const passwordInput = screen.getByLabelText(/password/i);
expect(loginInput).toBeInTheDocument();
expect(passwordInput).toBeInTheDocument();
});
test('handles input change correctly', () => {
renderComponent();
const loginInput: HTMLInputElement = screen.getByLabelText(/login/i);
const passwordInput: HTMLInputElement = screen.getByLabelText(/password/i);
fireEvent.change(loginInput, { target: { value: 'testuser' } });
fireEvent.change(passwordInput, { target: { value: 'testpassword' } });
expect(loginInput.value).toBe('testuser');
expect(passwordInput.value).toBe('testpassword');
});
test('submits form', () => {
renderComponent();
const loginInput = screen.getByLabelText(/login/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByText(/submit/i);
fireEvent.change(loginInput, { target: { value: 'testuser' } });
fireEvent.change(passwordInput, { target: { value: 'testpassword' } });
act(() => {
submitButton.click();
});
expect(submitButton).toBeDisabled();
});
import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { loadingUserSelector } from '@/redux/user/user.selectors';
import { login } from '@/redux/user/user.thunks';
import { Button } from '@/shared/Button';
import Link from 'next/link';
import React from 'react';
export const LoginModal: React.FC = () => {
const dispatch = useAppDispatch();
const loadingUser = useAppSelector(loadingUserSelector);
const isPending = loadingUser === 'pending';
const [credentials, setCredentials] = React.useState({ login: '', password: '' });
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const { name, value } = e.target;
setCredentials(prevCredentials => ({ ...prevCredentials, [name]: value }));
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
e.preventDefault();
dispatch(login(credentials));
};
return (
<div className="w-[400px] border border-t-[#E1E0E6] bg-white p-[24px]">
<form onSubmit={handleSubmit}>
<label className="mb-5 block text-sm font-semibold" htmlFor="login">
Login:
<input
type="text"
name="login"
id="login"
placeholder="Your login here.."
value={credentials.login}
onChange={handleChange}
className="mt-2.5 h-10 w-full rounded-s border border-transparent bg-cultured px-2 py-2.5 text-sm font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600"
/>
</label>
<label className="text-sm font-semibold" htmlFor="password">
Password:
<input
type="password"
name="password"
id="password"
placeholder="Your password here.."
value={credentials.password}
onChange={handleChange}
className="mt-2.5 h-10 w-full rounded-s border border-transparent bg-cultured px-2 py-2.5 text-sm font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600"
/>
</label>
<div className="mb-10 text-right">
<Link href="/" className="ml-auto text-xs">
Forgot password?
</Link>
</div>
<Button
type="submit"
className="w-full justify-center text-base font-medium"
disabled={isPending}
>
Submit
</Button>
</form>
</div>
);
};
export { LoginModal } from './LoginModal.component';
......@@ -6,6 +6,7 @@ import { Icon } from '@/shared/Icon';
import { twMerge } from 'tailwind-merge';
import { MODAL_ROLE } from './Modal.constants';
import { OverviewImagesModal } from './OverviewImagesModal';
import { LoginModal } from './LoginModal';
export const Modal = (): React.ReactNode => {
const dispatch = useAppDispatch();
......@@ -24,7 +25,12 @@ export const Modal = (): React.ReactNode => {
role={MODAL_ROLE}
>
<div className="flex h-full w-full items-center justify-center">
<div className="flex h-5/6 w-10/12 flex-col overflow-hidden rounded-lg">
<div
className={twMerge(
'flex h-5/6 w-10/12 flex-col overflow-hidden rounded-lg',
modalName === 'login' && 'h-auto w-[400px]',
)}
>
<div className="flex items-center justify-between bg-white p-[24px] text-xl">
<div>{modalTitle}</div>
<button type="button" onClick={handleCloseModal} aria-label="close button">
......@@ -32,6 +38,7 @@ export const Modal = (): React.ReactNode => {
</button>
</div>
{isOpen && modalName === 'overview-images' && <OverviewImagesModal />}
{isOpen && modalName === 'login' && <LoginModal />}
</div>
</div>
</div>
......
import { DrawerHeading } from '@/shared/DrawerHeading';
import { GeneralOverlays } from './GeneralOverlays';
import { UserOverlays } from './UserOverlays';
export const OverlaysDrawer = (): JSX.Element => {
return (
<div data-testid="overlays-drawer">
<div data-testid="overlays-drawer" className="h-full max-h-full">
<DrawerHeading title="Overlays" />
<GeneralOverlays />
<div className="h-[calc(100%-93px)] max-h-[calc(100%-93px)] overflow-y-auto">
<GeneralOverlays />
<UserOverlays />
</div>
</div>
);
};
import { useAppDispatch } from '@/redux/hooks/useAppDispatch';
import { useAppSelector } from '@/redux/hooks/useAppSelector';
import { openLoginModal } from '@/redux/modal/modal.slice';
import { authenticatedUserSelector, loadingUserSelector } from '@/redux/user/user.selectors';
import { Button } from '@/shared/Button';
export const UserOverlays = (): JSX.Element => {
const dispatch = useAppDispatch();
const loadingUser = useAppSelector(loadingUserSelector);
const authenticatedUser = useAppSelector(authenticatedUserSelector);
const handleLoginClick = (): void => {
dispatch(openLoginModal());
};
return (
<div className="p-6">
{loadingUser === 'pending' && <h1>Loading</h1>}
{loadingUser !== 'pending' && !authenticatedUser && (
<>
<p className="mb-5 font-semibold">User provided overlays:</p>
<p className="mb-5 text-sm">
You are not logged in, please login to upload and view custom overlays
</p>
<Button onClick={handleLoginClick}>Login</Button>
</>
)}
{/* TODO: Implement user overlays */}
{authenticatedUser && <h1>Authenticated</h1>}
</div>
);
};
export { UserOverlays } from './UserOverlays.component';
import { ZOD_SEED } from '@/constants';
import { loginSchema } from '@/models/loginSchema';
// eslint-disable-next-line import/no-extraneous-dependencies
import { createFixture } from 'zod-fixture';
export const loginFixture = createFixture(loginSchema, {
seed: ZOD_SEED,
});
import { ZOD_SEED } from '@/constants';
// eslint-disable-next-line import/no-extraneous-dependencies
import { createFixture } from 'zod-fixture';
import { sessionSchemaValid } from '../sessionValidSchema';
export const sessionFixture = createFixture(sessionSchemaValid, {
seed: ZOD_SEED,
});
import { z } from 'zod';
export const loginSchema = z.object({
info: z.string(),
login: z.string(),
token: z.string(),
});
import { z } from 'zod';
export const sessionSchemaValid = z.object({
login: z.string(),
});
......@@ -29,6 +29,8 @@ export const apiPath = {
getAllBackgroundsByProjectIdQuery: (projectId: string): string =>
`projects/${projectId}/backgrounds/`,
getProjectById: (projectId: string): string => `projects/${projectId}`,
getSessionValid: (): string => `users/isSessionValid`,
postLogin: (): string => `doLogin`,
getConfigurationOptions: (): string => 'configuration/options/',
getOverlayBioEntity: ({ overlayId, modelId }: { overlayId: number; modelId: number }): string =>
`projects/${PROJECT_ID}/overlays/${overlayId}/models/${modelId}/bioEntities/`,
......
......@@ -24,6 +24,12 @@ export const openOverviewImagesModalByIdReducer = (
};
};
export const openLoginModalReducer = (state: ModalState): void => {
state.isOpen = true;
state.modalName = 'login';
state.modalTitle = 'You need to login';
};
export const setOverviewImageIdReducer = (
state: ModalState,
action: PayloadAction<number>,
......
......@@ -2,6 +2,7 @@ import { createSlice } from '@reduxjs/toolkit';
import { MODAL_INITIAL_STATE } from './modal.constants';
import {
closeModalReducer,
openLoginModalReducer,
openModalReducer,
openOverviewImagesModalByIdReducer,
setOverviewImageIdReducer,
......@@ -15,10 +16,16 @@ const modalSlice = createSlice({
closeModal: closeModalReducer,
openOverviewImagesModalById: openOverviewImagesModalByIdReducer,
setOverviewImageId: setOverviewImageIdReducer,
openLoginModal: openLoginModalReducer,
},
});
export const { openModal, closeModal, openOverviewImagesModalById, setOverviewImageId } =
modalSlice.actions;
export const {
openModal,
closeModal,
openOverviewImagesModalById,
setOverviewImageId,
openLoginModal,
} = modalSlice.actions;
export default modalSlice.reducer;
......@@ -16,6 +16,7 @@ import {
} from '../map/map.thunks';
import { getSearchData } from '../search/search.thunks';
import { setPerfectMatch } from '../search/search.slice';
import { getSessionValid } from '../user/user.thunks';
import { getConfigurationOptions } from '../configuration/configuration.thunks';
interface InitializeAppParams {
......@@ -44,6 +45,9 @@ export const fetchInitialAppData = createAsyncThunk<
/** Create tabs for maps / submaps */
dispatch(initOpenedMaps({ queryData }));
// Check if auth token is valid
dispatch(getSessionValid());
/** Trigger search */
if (queryData.searchValue) {
dispatch(setPerfectMatch(queryData.perfectMatch));
......
......@@ -13,6 +13,7 @@ import { PROJECT_STATE_INITIAL_MOCK } from '../project/project.mock';
import { REACTIONS_STATE_INITIAL_MOCK } from '../reactions/reactions.mock';
import { SEARCH_STATE_INITIAL_MOCK } from '../search/search.mock';
import { RootState } from '../store';
import { USER_INITIAL_STATE_MOCK } from '../user/user.mock';
export const INITIAL_STORE_STATE_MOCK: RootState = {
search: SEARCH_STATE_INITIAL_MOCK,
......@@ -29,4 +30,5 @@ export const INITIAL_STORE_STATE_MOCK: RootState = {
configuration: CONFIGURATION_INITIAL_STATE,
overlayBioEntity: OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK,
modal: MODAL_INITIAL_STATE_MOCK,
user: USER_INITIAL_STATE_MOCK,
};
......@@ -10,6 +10,7 @@ import overlaysReducer from '@/redux/overlays/overlays.slice';
import projectReducer from '@/redux/project/project.slice';
import reactionsReducer from '@/redux/reactions/reactions.slice';
import searchReducer from '@/redux/search/search.slice';
import userReducer from '@/redux/user/user.slice';
import configurationReducer from '@/redux/configuration/configuration.slice';
import overlayBioEntityReducer from '@/redux/overlayBioEntity/overlayBioEntity.slice';
import {
......@@ -34,6 +35,7 @@ export const reducers = {
overlays: overlaysReducer,
models: modelsReducer,
reactions: reactionsReducer,
user: userReducer,
configuration: configurationReducer,
overlayBioEntity: overlayBioEntityReducer,
};
......
import { UserState } from './user.types';
export const USER_INITIAL_STATE_MOCK: UserState = {
loading: 'idle',
authenticated: false,
error: { name: '', message: '' },
login: null,
};
import { login, getSessionValid } from '@/redux/user/user.thunks';
import type { UserState } from '@/redux/user/user.types';
import {
ToolkitStoreWithSingleSlice,
createStoreInstanceUsingSliceReducer,
} from '@/utils/createStoreInstanceUsingSliceReducer';
import { mockNetworkResponse } from '@/utils/mockNetworkResponse';
import { HttpStatusCode } from 'axios';
import { loginFixture } from '@/models/fixtures/loginFixture';
import { sessionFixture } from '@/models/fixtures/sessionFixture';
import { apiPath } from '../apiPath';
import userReducer from './user.slice';
const mockedAxiosClient = mockNetworkResponse();
const CREDENTIALS = {
login: 'test',
password: 'password',
};
const INITIAL_STATE: UserState = {
loading: 'idle',
authenticated: false,
error: { name: '', message: '' },
login: null,
};
describe('user reducer', () => {
let store = {} as ToolkitStoreWithSingleSlice<UserState>;
beforeEach(() => {
store = createStoreInstanceUsingSliceReducer('user', userReducer);
});
it('should match initial state', () => {
const action = { type: 'unknown' };
expect(userReducer(undefined, action)).toEqual(INITIAL_STATE);
});
it('should update store after successful login query', async () => {
mockedAxiosClient.onPost(apiPath.postLogin()).reply(HttpStatusCode.Ok, loginFixture);
await store.dispatch(login(CREDENTIALS));
const { authenticated, loading } = store.getState().user;
expect(authenticated).toBe(true);
expect(loading).toEqual('succeeded');
});
it('should update store on loading login query', async () => {
mockedAxiosClient.onPost(apiPath.postLogin()).reply(HttpStatusCode.Ok, loginFixture);
const loginPromise = store.dispatch(login(CREDENTIALS));
const { authenticated, loading } = store.getState().user;
expect(authenticated).toBe(false);
expect(loading).toEqual('pending');
await loginPromise;
const { authenticated: authenticatedFulfilled, loading: promiseFulfilled } =
store.getState().user;
expect(authenticatedFulfilled).toBe(true);
expect(promiseFulfilled).toEqual('succeeded');
});
it('should update store after successful getSessionValid query', async () => {
mockedAxiosClient.onGet(apiPath.getSessionValid()).reply(HttpStatusCode.Ok, sessionFixture);
await store.dispatch(getSessionValid());
const { authenticated, loading, login: sessionLogin } = store.getState().user;
expect(authenticated).toBe(true);
expect(loading).toEqual('succeeded');
expect(sessionLogin).toBeDefined();
});
});
import { ActionReducerMapBuilder } from '@reduxjs/toolkit';
import { getSessionValid, login } from './user.thunks';
import { UserState } from './user.types';
export const loginReducer = (builder: ActionReducerMapBuilder<UserState>): void => {
builder
.addCase(login.pending, state => {
state.loading = 'pending';
})
.addCase(login.fulfilled, state => {
state.authenticated = true;
state.loading = 'succeeded';
})
.addCase(login.rejected, state => {
state.authenticated = false;
state.loading = 'failed';
});
};
export const getSessionValidReducer = (builder: ActionReducerMapBuilder<UserState>): void => {
builder
.addCase(getSessionValid.pending, state => {
state.loading = 'pending';
})
.addCase(getSessionValid.fulfilled, (state, action) => {
state.authenticated = true;
state.loading = 'succeeded';
state.login = action.payload;
})
.addCase(getSessionValid.rejected, state => {
state.authenticated = false;
state.loading = 'failed';
// TODO: error management to be discussed in the team
});
};
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment