GF-12-breadcrumbs #11
19 changed files with 200 additions and 33 deletions
|
|
@ -43,7 +43,7 @@ const doLogin = async () => {
|
||||||
<Button type="submit">Login</Button>
|
<Button type="submit">Login</Button>
|
||||||
<FieldDescription class="text-center">
|
<FieldDescription class="text-center">
|
||||||
Don't have an account?
|
Don't have an account?
|
||||||
<NuxtLink to="/member/auth/create-account">
|
<NuxtLink to="/auth/create-account">
|
||||||
<Button variant="link">Create account</Button>
|
<Button variant="link">Create account</Button>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ const createAccount = async () => {
|
||||||
<Button type="submit">Create Account</Button>
|
<Button type="submit">Create Account</Button>
|
||||||
<FieldDescription class="text-center">
|
<FieldDescription class="text-center">
|
||||||
Already have an account?
|
Already have an account?
|
||||||
<NuxtLink to="/member/auth/login">
|
<NuxtLink to="/auth/login">
|
||||||
<Button variant="link">Log in</Button>
|
<Button variant="link">Log in</Button>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAuthStore } from "~/stores/auth";
|
import { useAuthStore } from "~/stores/auth";
|
||||||
|
|
||||||
import DefaultSidebar from "~/layouts/default/Sidebar.vue";
|
import DefaultSidebar from "~/layouts/default/Sidebar.vue";
|
||||||
|
import DefaultBreadcrumb from "~/layouts/default/Breadcrumb.vue";
|
||||||
|
|
||||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "~/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from "~/components/ui/sidebar";
|
||||||
|
|
||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
} from "~/components/ui/breadcrumb";
|
|
||||||
|
|
||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
|
|
||||||
import { useRuntimeConfig } from "#app";
|
import { useRuntimeConfig } from "#app";
|
||||||
|
|
@ -32,17 +25,7 @@ await authStore.init();
|
||||||
<header class="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
<header class="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||||||
<SidebarTrigger class="-ml-1" />
|
<SidebarTrigger class="-ml-1" />
|
||||||
<Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" />
|
<Separator orientation="vertical" class="mr-2 data-[orientation=vertical]:h-4" />
|
||||||
<Breadcrumb>
|
<DefaultBreadcrumb />
|
||||||
<BreadcrumbList>
|
|
||||||
<BreadcrumbItem class="hidden md:block">
|
|
||||||
<BreadcrumbLink href="#"> Building Your Application </BreadcrumbLink>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
<BreadcrumbSeparator class="hidden md:block" />
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
</header>
|
</header>
|
||||||
<main class="flex flex-1 flex-col gap-4 p-4">
|
<main class="flex flex-1 flex-col gap-4 p-4">
|
||||||
<slot />
|
<slot />
|
||||||
|
|
|
||||||
35
app/layouts/default/Breadcrumb.vue
Normal file
35
app/layouts/default/Breadcrumb.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "~/components/ui/breadcrumb";
|
||||||
|
|
||||||
|
import { useBreadcrumbStore } from "~/stores/breadcrumbs";
|
||||||
|
const breadcrumbStore = useBreadcrumbStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ClientOnly>
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<template v-for="i in breadcrumbStore.items.length - 1" :key="i">
|
||||||
|
<BreadcrumbItem class="hidden md:block">
|
||||||
|
<BreadcrumbLink :href="breadcrumbStore.items[i - 1]?.to || ''">
|
||||||
|
{{ breadcrumbStore.items[i - 1]?.label }}
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator class="hidden md:block" />
|
||||||
|
</template>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage>
|
||||||
|
{{ breadcrumbStore.items[breadcrumbStore.items.length - 1]?.label }}
|
||||||
|
</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
</ClientOnly>
|
||||||
|
</template>
|
||||||
|
|
@ -29,11 +29,11 @@ const userInititials = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
navigateTo("/member/auth/logout");
|
navigateTo("/auth/logout");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogin = () => {
|
const handleLogin = () => {
|
||||||
navigateTo("/member/auth/login");
|
navigateTo("/auth/login");
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useBreadcrumbStore } from "~/stores/breadcrumbs";
|
||||||
import SignupForm from "@/components/SignupForm.vue";
|
import SignupForm from "@/components/SignupForm.vue";
|
||||||
|
|
||||||
|
const breadcrumbStore = useBreadcrumbStore();
|
||||||
|
breadcrumbStore.setBreadcrumbs([{ label: "Auth" }, { label: "Create Account", to: "/auth/create-account" }]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import LoginForm from "@/components/LoginForm.vue";
|
import { useBreadcrumbStore } from "~/stores/breadcrumbs";
|
||||||
|
import LoginForm from "~/components/LoginForm.vue";
|
||||||
|
|
||||||
|
const breadcrumbStore = useBreadcrumbStore();
|
||||||
|
breadcrumbStore.setBreadcrumbs([{ label: "Auth" }, { label: "Login", to: "/auth/login" }]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -4,9 +4,13 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||||
import { Field, FieldDescription } from "@/components/ui/field";
|
import { Field, FieldDescription } from "@/components/ui/field";
|
||||||
|
|
||||||
import { useAuthStore } from "~/stores/auth";
|
import { useAuthStore } from "~/stores/auth";
|
||||||
|
import { useBreadcrumbStore } from "~/stores/breadcrumbs";
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
await authStore.init();
|
await authStore.init();
|
||||||
|
|
||||||
|
const breadcrumbStore = useBreadcrumbStore();
|
||||||
|
breadcrumbStore.setBreadcrumbs([{ label: "Auth" }, { label: "Logout", to: "/auth/logout" }]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
import { useBreadcrumbStore } from "~/stores/breadcrumbs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
const lastClicked = ref<string>("None");
|
const lastClicked = ref<string>("None");
|
||||||
|
|
@ -7,6 +8,9 @@ const lastClicked = ref<string>("None");
|
||||||
const buttonClicked = (variant: string) => {
|
const buttonClicked = (variant: string) => {
|
||||||
lastClicked.value = variant;
|
lastClicked.value = variant;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const breadcrumbStore = useBreadcrumbStore();
|
||||||
|
breadcrumbStore.setBreadcrumbs([{ label: "Homepage", to: "/" }]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
||||||
24
app/stores/breadcrumbs.ts
Normal file
24
app/stores/breadcrumbs.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBreadcrumbStore = defineStore("breadcrumb", {
|
||||||
|
state: () => ({
|
||||||
|
items: [] as BreadcrumbItem[],
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
setBreadcrumbs(items: BreadcrumbItem[]) {
|
||||||
|
this.items = items;
|
||||||
|
},
|
||||||
|
addBreadcrumb(item: BreadcrumbItem) {
|
||||||
|
this.items.push(item);
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
this.items = [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -166,7 +166,7 @@ describe("SidebarFooter.vue", () => {
|
||||||
expect(wrapper.text()).not.toContain("Log out");
|
expect(wrapper.text()).not.toContain("Log out");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls navigateTo('/member/auth/logout') when Log out is clicked", async () => {
|
it("calls navigateTo('/auth/logout') when Log out is clicked", async () => {
|
||||||
const wrapper = mount(SidebarFooterComponent, {
|
const wrapper = mount(SidebarFooterComponent, {
|
||||||
props: { user },
|
props: { user },
|
||||||
global: {
|
global: {
|
||||||
|
|
@ -181,10 +181,10 @@ describe("SidebarFooter.vue", () => {
|
||||||
expect(logoutItem).toBeDefined();
|
expect(logoutItem).toBeDefined();
|
||||||
await logoutItem?.trigger("click");
|
await logoutItem?.trigger("click");
|
||||||
|
|
||||||
expect(navigateToMock).toHaveBeenCalledWith("/member/auth/logout");
|
expect(navigateToMock).toHaveBeenCalledWith("/auth/logout");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls navigateTo('/member/auth/login') when Log in is clicked", async () => {
|
it("calls navigateTo('/auth/login') when Log in is clicked", async () => {
|
||||||
const wrapper = mount(SidebarFooterComponent, {
|
const wrapper = mount(SidebarFooterComponent, {
|
||||||
props: { user: null },
|
props: { user: null },
|
||||||
global: {
|
global: {
|
||||||
|
|
@ -199,7 +199,7 @@ describe("SidebarFooter.vue", () => {
|
||||||
expect(loginItem).toBeDefined();
|
expect(loginItem).toBeDefined();
|
||||||
await loginItem?.trigger("click");
|
await loginItem?.trigger("click");
|
||||||
|
|
||||||
expect(navigateToMock).toHaveBeenCalledWith("/member/auth/login");
|
expect(navigateToMock).toHaveBeenCalledWith("/auth/login");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("computes initials correctly for single name", () => {
|
it("computes initials correctly for single name", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { mount, flushPromises } from "@vue/test-utils";
|
import { mount, flushPromises } from "@vue/test-utils";
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import CreateAccountPage from "~/pages/member/auth/create-account.vue";
|
import CreateAccountPage from "~/pages/auth/create-account.vue";
|
||||||
|
|
||||||
// Mock auth client
|
// Mock auth client
|
||||||
const authMocks = vi.hoisted(() => ({
|
const authMocks = vi.hoisted(() => ({
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { mount, flushPromises } from "@vue/test-utils";
|
import { mount, flushPromises } from "@vue/test-utils";
|
||||||
import { describe, expect, it, vi, beforeEach, beforeAll, afterAll } from "vitest";
|
import { describe, expect, it, vi, beforeEach, beforeAll, afterAll } from "vitest";
|
||||||
import LoginPage from "~/pages/member/auth/login.vue";
|
import LoginPage from "~/pages/auth/login.vue";
|
||||||
|
|
||||||
// Mock the auth store
|
// Mock the auth store
|
||||||
const authStoreMocks = vi.hoisted(() => ({
|
const authStoreMocks = vi.hoisted(() => ({
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { mount, flushPromises } from "@vue/test-utils";
|
import { mount, flushPromises } from "@vue/test-utils";
|
||||||
import { describe, expect, it, vi, beforeEach, beforeAll, afterAll } from "vitest";
|
import { describe, expect, it, vi, beforeEach, beforeAll, afterAll } from "vitest";
|
||||||
import LogoutPage from "~/pages/member/auth/logout.vue";
|
import LogoutPage from "~/pages/auth/logout.vue";
|
||||||
|
|
||||||
// Mock the auth store
|
// Mock the auth store
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, expect, it, beforeEach } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { mount, type VueWrapper } from "@vue/test-utils";
|
import { mount, type VueWrapper } from "@vue/test-utils";
|
||||||
import IndexPage from "~/pages/index.vue";
|
import IndexPage from "~/pages/index.vue";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,16 @@ Object.defineProperty(global, "import", {
|
||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const breadcrumbStoreMocks = vi.hoisted(() => ({
|
||||||
|
setBreadcrumbs: vi.fn(),
|
||||||
|
addBreadcrumb: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
items: [{ label: "Auth" }, { label: "Create Account", to: "/auth/create-account" }],
|
||||||
|
}));
|
||||||
|
vi.mock("~/stores/breadcrumbs", () => ({
|
||||||
|
useBreadcrumbStore: () => breadcrumbStoreMocks,
|
||||||
|
}));
|
||||||
|
|
||||||
config.global.stubs = {
|
config.global.stubs = {
|
||||||
NuxtLayout: true,
|
NuxtLayout: true,
|
||||||
NuxtPage: true,
|
NuxtPage: true,
|
||||||
|
|
|
||||||
23
tests/shared/utils/auth-client.test.ts
Normal file
23
tests/shared/utils/auth-client.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { authClient } from "#shared/utils/auth-client";
|
||||||
|
import { createAuthClient } from "better-auth/vue";
|
||||||
|
|
||||||
|
const vars = vi.hoisted(() => ({
|
||||||
|
mockAuthClient: {
|
||||||
|
signIn: vi.fn(),
|
||||||
|
signUp: vi.fn(),
|
||||||
|
signOut: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("better-auth/vue", () => ({
|
||||||
|
createAuthClient: vi.fn(() => vars.mockAuthClient),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("auth-client utility", () => {
|
||||||
|
it("should create and export the auth client", () => {
|
||||||
|
expect(createAuthClient).toHaveBeenCalled();
|
||||||
|
expect(authClient).toBe(vars.mockAuthClient);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
tests/shared/utils/env.test.ts
Normal file
36
tests/shared/utils/env.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/* eslint-disable node/no-process-env */
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
describe("shared/utils/env", () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
process.env.NODE_ENV = "test";
|
||||||
|
process.env.DATABASE_URL = "postgres://localhost:5432/db";
|
||||||
|
process.env.BETTER_AUTH_SECRET = "secret";
|
||||||
|
process.env.BETTER_AUTH_URL = "http://localhost:3000";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate and export variables when all required variables are present", async () => {
|
||||||
|
const env = (await import("#shared/utils/env")).default;
|
||||||
|
|
||||||
|
expect(env).toEqual({
|
||||||
|
NODE_ENV: "test",
|
||||||
|
DATABASE_URL: "postgres://localhost:5432/db",
|
||||||
|
BETTER_AUTH_SECRET: "secret",
|
||||||
|
BETTER_AUTH_URL: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw an error if NODE_ENV is missing", async () => {
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
|
await expect(import("#shared/utils/env")).rejects.toThrow(z.ZodError);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
tests/stores/breadcrumbs.test.ts
Normal file
40
tests/stores/breadcrumbs.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createPinia, setActivePinia } from "pinia";
|
||||||
|
import { useBreadcrumbStore } from "~/stores/breadcrumbs";
|
||||||
|
|
||||||
|
vi.unmock("~/stores/breadcrumbs");
|
||||||
|
|
||||||
|
describe("useBreadcrumbStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("init", () => {
|
||||||
|
it("should initialize", () => {
|
||||||
|
const store = useBreadcrumbStore();
|
||||||
|
expect(store.items.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clear should remove all breadcrumbs", () => {
|
||||||
|
const store = useBreadcrumbStore();
|
||||||
|
store.addBreadcrumb({ label: "Test", to: "/test" });
|
||||||
|
store.clear();
|
||||||
|
expect(store.items.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addBreadcrumb should add a breadcrumb", () => {
|
||||||
|
const store = useBreadcrumbStore();
|
||||||
|
store.addBreadcrumb({ label: "Test", to: "/test" });
|
||||||
|
expect(store.items.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setBreadcrumbs should set breadcrumbs", () => {
|
||||||
|
const store = useBreadcrumbStore();
|
||||||
|
store.setBreadcrumbs([
|
||||||
|
{ label: "Test", to: "/test" },
|
||||||
|
{ label: "Test 2", to: "/test2" },
|
||||||
|
]);
|
||||||
|
expect(store.items.length).toEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue