feat(web): finish implementing login
This commit is contained in:
@@ -1,11 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
import { mount } from "@vue/test-utils";
|
|
||||||
import App from "../App.vue";
|
|
||||||
|
|
||||||
describe("App", () => {
|
|
||||||
it("mounts renders properly", () => {
|
|
||||||
const wrapper = mount(App);
|
|
||||||
expect(wrapper.text()).toContain("You did it!");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
|
import LoginForm from '@/components/LoginForm.vue';
|
||||||
|
|
||||||
|
describe('LoginForm', () => {
|
||||||
|
it('should have username field', () => {
|
||||||
|
const wrapper = mount(LoginForm);
|
||||||
|
|
||||||
|
const username = wrapper.get('#username');
|
||||||
|
|
||||||
|
expect(username.element.tagName).toBe('INPUT');
|
||||||
|
expect(username.attributes()['type']).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have password field', () => {
|
||||||
|
const wrapper = mount(LoginForm);
|
||||||
|
|
||||||
|
const password = wrapper.get('#password');
|
||||||
|
|
||||||
|
expect(password.element.tagName).toBe('INPUT');
|
||||||
|
expect(password.attributes()['type']).toBe('password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require username field on submission', async () => {
|
||||||
|
const wrapper = mount(LoginForm, {
|
||||||
|
attachTo: document.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click');
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Username is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require password field on submission', async () => {
|
||||||
|
const wrapper = mount(LoginForm, {
|
||||||
|
attachTo: document.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click');
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Password is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset error state when new input is entered', async () => {
|
||||||
|
const wrapper = mount(LoginForm, {
|
||||||
|
attachTo: document.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click');
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Username is required');
|
||||||
|
expect(wrapper.text()).toContain('Password is required');
|
||||||
|
|
||||||
|
const usernameInput = wrapper.get('#username');
|
||||||
|
const passwordInput = wrapper.get('#password');
|
||||||
|
|
||||||
|
await usernameInput.setValue('Stevan');
|
||||||
|
await passwordInput.setValue('password');
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('Username is required');
|
||||||
|
expect(wrapper.text()).not.toContain('Password is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable login button while submitting', async () => {
|
||||||
|
let resolveSubmit;
|
||||||
|
|
||||||
|
const wrapper = mount(LoginForm, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: {
|
||||||
|
onValidSubmit: () => new Promise(resolve => {
|
||||||
|
resolveSubmit = resolve;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const usernameInput = wrapper.get('#username');
|
||||||
|
const passwordInput = wrapper.get('#password');
|
||||||
|
const submitButton = wrapper.get('button');
|
||||||
|
|
||||||
|
await usernameInput.setValue('Stevan');
|
||||||
|
await passwordInput.setValue('password');
|
||||||
|
await submitButton.trigger('click');
|
||||||
|
|
||||||
|
expect('disabled' in submitButton.attributes()).toBe(true);
|
||||||
|
|
||||||
|
resolveSubmit!();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect('disabled' in submitButton.attributes()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the onValidSubmit function when submitted with valid state', async () => {
|
||||||
|
let wasCalled = false;
|
||||||
|
|
||||||
|
const wrapper = mount(LoginForm, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: {
|
||||||
|
onValidSubmit: () => new Promise(resolve => {
|
||||||
|
wasCalled = true;
|
||||||
|
resolve();
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const usernameInput = wrapper.get('#username');
|
||||||
|
const passwordInput = wrapper.get('#password');
|
||||||
|
const submitButton = wrapper.get('button');
|
||||||
|
|
||||||
|
await usernameInput.setValue('Stevan');
|
||||||
|
await passwordInput.setValue('password');
|
||||||
|
await submitButton.trigger('click');
|
||||||
|
|
||||||
|
expect(wasCalled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export type LoginFormState = {
|
||||||
|
isLoggingIn: boolean;
|
||||||
|
username: {
|
||||||
|
value: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
password: {
|
||||||
|
value: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
onValidSubmit?: (formState: LoginFormState) => Promise<void> | void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const formState = ref<LoginFormState>({
|
||||||
|
isLoggingIn: false,
|
||||||
|
username: {
|
||||||
|
value: '',
|
||||||
|
error: '',
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
value: '',
|
||||||
|
error: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleUsernameInput(e: Event) {
|
||||||
|
formState.value.username.value = (e.currentTarget as HTMLInputElement).value;
|
||||||
|
|
||||||
|
if (formState.value.username.error.trim()) {
|
||||||
|
formState.value.username.error = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePasswordInput(e: Event) {
|
||||||
|
formState.value.password.value = (e.currentTarget as HTMLInputElement).value;
|
||||||
|
|
||||||
|
if (formState.value.password.error.trim()) {
|
||||||
|
formState.value.password.error = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFormState() {
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
if (!formState.value.username.value.trim()) {
|
||||||
|
formState.value.username.error = 'Username is required';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formState.value.password.value.trim()) {
|
||||||
|
formState.value.password.error = 'Password is required';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
formState.value.isLoggingIn = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (validateFormState() === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.onValidSubmit !== undefined) {
|
||||||
|
await props.onValidSubmit(formState.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
formState.value.isLoggingIn = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div>
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" name="username" id="username" :value="formState.username.value" @input="handleUsernameInput" />
|
||||||
|
<div class="error">{{ formState.username.error }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" name="password" id="password" :value="formState.password.value"
|
||||||
|
@input="handlePasswordInput" />
|
||||||
|
<div class="error">{{ formState.password.error }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" :disabled="formState.isLoggingIn">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
form,
|
||||||
|
form>div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form>div {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form>div label {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
form>div input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border: 1px solid black;
|
||||||
|
background-color: var(--bg-element);
|
||||||
|
}
|
||||||
|
|
||||||
|
form>div button {
|
||||||
|
background-color: var(--brand-primary);
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form>div button:disabled {
|
||||||
|
opacity: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
form>div .error {
|
||||||
|
color: var(--state-error);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import LoginForm, { type LoginFormState } from '@/components/LoginForm.vue';
|
||||||
import { useAuthService } from '@/composables/useAuthService';
|
import { useAuthService } from '@/composables/useAuthService';
|
||||||
import { useUserStore } from '@/stores/userStore';
|
import { useUserStore } from '@/stores/userStore';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
@@ -7,12 +8,8 @@ const router = useRouter();
|
|||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const authService = useAuthService(userStore);
|
const authService = useAuthService(userStore);
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleValideSubmit(formState: LoginFormState) {
|
||||||
const data = Object.fromEntries(new FormData(e.currentTarget as HTMLFormElement));
|
const loginResult = await authService.login(formState.username.value, formState.password.value);
|
||||||
const loginResult = await authService.login(
|
|
||||||
data['username']!.toString(),
|
|
||||||
data['password']!.toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loginResult.err) {
|
if (loginResult.err) {
|
||||||
alert(loginResult.val.map(e => e.message).join('\n'));
|
alert(loginResult.val.map(e => e.message).join('\n'));
|
||||||
@@ -25,53 +22,7 @@ async function handleSubmit(e: SubmitEvent) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="handleSubmit">
|
<LoginForm :onValidSubmit="handleValideSubmit" />
|
||||||
<div>
|
|
||||||
<label for="username">Username</label>
|
|
||||||
<input type="text" name="username" id="username" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="password">Password</label>
|
|
||||||
<input type="password" name="password" id="password" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button type="submit">Login</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
form,
|
|
||||||
form > div {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background-color: var(--bg-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
form {
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
form > div {
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
form > div label {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
form > div input {
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
border: 1px solid black;
|
|
||||||
background-color: var(--bg-element);
|
|
||||||
}
|
|
||||||
|
|
||||||
form > div button {
|
|
||||||
background-color: var(--brand-primary);
|
|
||||||
padding: 0.5rem 0.25rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user