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">
|
||||
import LoginForm, { type LoginFormState } from '@/components/LoginForm.vue';
|
||||
import { useAuthService } from '@/composables/useAuthService';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { useRouter } from 'vue-router';
|
||||
@@ -7,12 +8,8 @@ const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const authService = useAuthService(userStore);
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
const data = Object.fromEntries(new FormData(e.currentTarget as HTMLFormElement));
|
||||
const loginResult = await authService.login(
|
||||
data['username']!.toString(),
|
||||
data['password']!.toString()
|
||||
);
|
||||
async function handleValideSubmit(formState: LoginFormState) {
|
||||
const loginResult = await authService.login(formState.username.value, formState.password.value);
|
||||
|
||||
if (loginResult.err) {
|
||||
alert(loginResult.val.map(e => e.message).join('\n'));
|
||||
@@ -25,53 +22,7 @@ async function handleSubmit(e: SubmitEvent) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<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>
|
||||
<LoginForm :onValidSubmit="handleValideSubmit" />
|
||||
</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;
|
||||
}
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
Reference in New Issue
Block a user