feat(web): finish implementing login

This commit is contained in:
Stevan Freeborn
2026-02-21 07:28:07 -06:00
parent 3449347bb4
commit 19ea205ac7
4 changed files with 268 additions and 65 deletions
@@ -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>
+5 -54
View File
@@ -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>