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);
};
}