diff --git a/packages/react/src/LoginForm/LoginForm.tsx b/packages/react/src/LoginForm/LoginForm.tsx new file mode 100644 index 00000000..c3c8c47a --- /dev/null +++ b/packages/react/src/LoginForm/LoginForm.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { useMutation } from '@tanstack/react-query'; +import Cookies from 'js-cookie'; + +import { Attribute } from '../lib/SimpleForm/types'; +import { useNileConfig } from '../context'; +import SimpleForm from '../lib/SimpleForm'; +import { AttributeType } from '../lib/SimpleForm/types'; + +import { Props, AllowedAny } from './types'; + +export default function LoginForm(props: Props) { + const { workspace, database, basePath, allowClientCookies } = useNileConfig(); + + const { attributes, onSuccess, onError, beforeMutate } = props; + const fetchPath = `${basePath}/workspaces/${workspace}/databases/${database}/users/login`; + + const handleMutate = + typeof beforeMutate === 'function' + ? beforeMutate + : (data: AllowedAny): AllowedAny => data; + + const mutation = useMutation( + async (data: { email: string; password: string }) => { + const _data = handleMutate(data); + const res = await fetch(fetchPath, { + method: 'POST', + body: JSON.stringify(_data), + headers: { + 'content-type': 'application/json', + }, + }).catch((e) => e); + if (res.ok === false) { + throw new Error(res.status); + } + try { + return await res.json(); + } catch (e) { + return e; + } + }, + { + onSuccess: (token, data) => { + if (token) { + if (allowClientCookies) { + Cookies.set('token', token.token, { + 'max-age': token.maxAge, + }); + } + + onSuccess && onSuccess(token, data); + } + }, + onError: (error, data) => { + onError && onError(error as Error, data); + }, + } + ); + + const completeAttributes = React.useMemo(() => { + const mainAttributes: Attribute[] = [ + { + name: 'email', + label: 'Email', + type: AttributeType.Text, + defaultValue: '', + required: true, + }, + { + name: 'password', + label: 'Password', + type: AttributeType.Password, + defaultValue: '', + required: true, + }, + ]; + if (attributes && attributes.length > 0) { + return mainAttributes.concat(attributes); + } + return mainAttributes; + }, [attributes]); + + return ( + + ); +} diff --git a/packages/react/src/LoginForm/index.tsx b/packages/react/src/LoginForm/index.tsx new file mode 100644 index 00000000..1195444e --- /dev/null +++ b/packages/react/src/LoginForm/index.tsx @@ -0,0 +1 @@ +export { default } from './LoginForm'; diff --git a/packages/react/src/LoginForm/types.ts b/packages/react/src/LoginForm/types.ts new file mode 100644 index 00000000..83c0780f --- /dev/null +++ b/packages/react/src/LoginForm/types.ts @@ -0,0 +1,18 @@ +import { Token } from '@theniledev/js'; + +import { Attribute } from '../lib/SimpleForm/types'; + +type LoginSuccess = ( + token: Token, + LoginInfo: { email: string; password: string } +) => void; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AllowedAny = any; + +export interface Props { + beforeMutate?: (data: AllowedAny) => AllowedAny; + onSuccess: LoginSuccess; + onError?: (error: Error, data: AllowedAny) => void; + attributes?: Attribute[]; +} diff --git a/packages/react/src/SignUpForm/SignUpForm.tsx b/packages/react/src/SignUpForm/SignUpForm.tsx index 8fd4162b..f3ecfc6f 100644 --- a/packages/react/src/SignUpForm/SignUpForm.tsx +++ b/packages/react/src/SignUpForm/SignUpForm.tsx @@ -28,13 +28,13 @@ export default function SignUpForm(props: Props) { 'content-type': 'application/json', }, }).catch((e) => e); + + if (res.ok === false) { + throw new Error(res.status); + } + try { if (res) { - if (allowClientCookies) { - Cookies.set('token', res.token.token, { - 'max-age': res.token.maxAge, - }); - } return await res.json(); } } catch (e) { @@ -42,7 +42,12 @@ export default function SignUpForm(props: Props) { } }, { - onSuccess: (_, data) => { + onSuccess: (res, data) => { + if (allowClientCookies) { + Cookies.set('token', res.token.token, { + 'max-age': res.token.maxAge, + }); + } onSuccess && onSuccess(data); }, onError: (error, data) => { diff --git a/packages/react/stories/LoginForm.stories.tsx b/packages/react/stories/LoginForm/LoginForm.stories.tsx similarity index 77% rename from packages/react/stories/LoginForm.stories.tsx rename to packages/react/stories/LoginForm/LoginForm.stories.tsx index 522b9a79..ac4ab894 100644 --- a/packages/react/stories/LoginForm.stories.tsx +++ b/packages/react/stories/LoginForm/LoginForm.stories.tsx @@ -2,22 +2,12 @@ import React from 'react'; import { Meta, Story } from '@storybook/react'; import Stack from '@mui/joy/Stack'; -import LoginForm from '../src/components/LoginForm'; -import GoogleLoginButton from '../src/GoogleLoginButton'; -import { NileProvider } from '../src/context'; +import LoginForm from '../../src/components/LoginForm'; +import GoogleLoginButton from '../../src/GoogleLoginButton'; +import { NileProvider } from '../../src/context'; const meta: Meta = { component: LoginForm, - argTypes: { - children: { - control: { - type: 'text', - }, - }, - }, - parameters: { - controls: { expanded: false }, - }, }; export default meta; diff --git a/packages/react/stories/LoginForm/UserLoginForm.stories.tsx b/packages/react/stories/LoginForm/UserLoginForm.stories.tsx new file mode 100644 index 00000000..476f0de1 --- /dev/null +++ b/packages/react/stories/LoginForm/UserLoginForm.stories.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Meta, Story } from '@storybook/react'; +import Stack from '@mui/joy/Stack'; + +import LoginForm from '../../src/LoginForm'; +import { NileProvider } from '../../src/context'; + +const meta: Meta = { + component: LoginForm, +}; + +export default meta; + +const Template: Story = () => ( + + + alert('success!')} /> + + +); + +// By passing using the Args format for exported stories, you can control the props for a component for reuse in a test +// https://storybook.js.org/docs/react/workflows/unit-testing +export const Default = Template.bind({}); diff --git a/packages/react/test/LoginForm/LoginForm.test.tsx b/packages/react/test/LoginForm/LoginForm.test.tsx new file mode 100644 index 00000000..dbba2fb3 --- /dev/null +++ b/packages/react/test/LoginForm/LoginForm.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { NileProvider } from '@theniledev/react'; +import Cookies from 'js-cookie'; +import '../matchMedia.mock'; + +import LoginForm from '../../src/LoginForm/LoginForm'; +import { token } from '../fetch.mock'; + +jest.mock('js-cookie'); + +describe('LoginForm', () => { + it('sets a js cookie by default', async () => { + const spy = jest.spyOn(Cookies, 'set'); + const onSuccess = jest.fn(); + global.fetch = token; + render( + + + + ); + const password = screen.getByPlaceholderText('Password'); + fireEvent.change(password, { target: { value: 'supersecret' } }); + + const email = screen.getByPlaceholderText('Email'); + fireEvent.change(email, { target: { value: 'squirrel@super.secret' } }); + + const button = screen.getByRole('button', { name: 'Log in' }); + fireEvent.click(button); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react/test/SignUpForm/SignUpForm.test.tsx b/packages/react/test/SignUpForm/SignUpForm.test.tsx index a55cd47b..8e8146ae 100644 --- a/packages/react/test/SignUpForm/SignUpForm.test.tsx +++ b/packages/react/test/SignUpForm/SignUpForm.test.tsx @@ -7,6 +7,8 @@ import '../matchMedia.mock'; import SignUpForm from '../../src/SignUpForm/SignUpForm'; import { token } from '../fetch.mock'; +jest.mock('js-cookie'); + describe('SignUpForm', () => { it('sets a js cookie by default', async () => { const spy = jest.spyOn(Cookies, 'set'); diff --git a/packages/react/test/fetch.mock.ts b/packages/react/test/fetch.mock.ts index cbe3b609..980cab39 100644 --- a/packages/react/test/fetch.mock.ts +++ b/packages/react/test/fetch.mock.ts @@ -9,7 +9,7 @@ class FakeResponse { }); } json = async () => { - return this.payload; + return JSON.parse(this.payload); }; }