A recipe for adding a route to the SPA, with a worked minimal example. After it comes a guide to moving an existing view onto the layers one step at a time. Read architecture.md first for what each layer may import.
features/<section>/use<Thing>.ts.
It loads the page’s data (through a store, or api + a mapper),
exposes it as computed refs, and holds the page’s actions. Use
useResource(key, loader) so a key change reloads and a stale
response is dropped.components/<section>/ (props in, events out). Anything that needs a
store, api or the router is a container, and goes in
features/<section>/ (components.md).views/<Page>View.vue, a route shell. It takes
route params as props, calls the feature composable, lays out
AppShell plus the features and components, and navigates by route
name. It stays under 400 lines. If it grows, move a section into a
feature component.AppRouteNamedMap in
router/types.ts.routes in router/index.ts, with
meta: AUTHENTICATED (or PUBLIC).:id, add props: true.components/layout/BottomNav.vue and SideNav.vue as
to: { name: "<route>" }.withSetup or a view test.mountWithApp: data from the mock API renders, the
error state shows, and navigation lands.tests/router/guards.test.ts row if the route is public.vue-tsc checks the typed route names.
pnpm fmt && pnpm type-check && pnpm test:coverage.This example adds a read-only page at /app/categories/ that lists the
transaction categories, grouped by group. The resource
(api.transactionCategories.list) and the model
(transactionCategoryFromDto) already exist.
// src/features/categories/useCategoryList.ts
//
// `useCategoryList`: every transaction category the user can see,
// grouped by `group`. Feature composable (categories).
//
// 3rd party imports
//
import { computed } from "vue";
// app imports
//
import { api } from "@/api";
import { useResource } from "@/composables/useResource";
import type { TransactionCategory } from "@/models/transactionCategory";
import { transactionCategoryFromDto } from "@/models/transactionCategory";
////////////////////////////////////////////////////////////////////////
//
export function useCategoryList() {
// A constant key: load once when the page mounts.
const resource = useResource(
() => "all",
async () => {
const first = await api.transactionCategories.list({ archived: false });
return (await api.pages.all(first)).map(transactionCategoryFromDto);
},
{ errorMessage: "Failed to load categories." },
);
const groups = computed(() => {
const byGroup = new Map<string, TransactionCategory[]>();
for (const c of resource.data.value ?? []) {
byGroup.set(c.group, [...(byGroup.get(c.group) ?? []), c]);
}
return [...byGroup].map(([group, categories]) => ({ group, categories }));
});
return { groups, loading: resource.loading, error: resource.error };
}
It is built from the Base* primitives and token utilities; see
styling.md for which to use.
<!-- src/components/categories/CategoryGroup.vue -->
<script setup lang="ts">
//
// CategoryGroup — one category group as a titled list. Presentational:
// emits `select` with the category id.
//
// app imports
//
import BaseCard from "@/components/base/BaseCard.vue";
import BaseListRow from "@/components/base/BaseListRow.vue";
import BaseSectionHeader from "@/components/base/BaseSectionHeader.vue";
import type { TransactionCategory } from "@/models/transactionCategory";
////////////////////////////////////////////////////////////////////////
//
defineProps<{ group: string; categories: TransactionCategory[] }>();
const emit = defineEmits<{ (e: "select", id: string): void }>();
</script>
<template>
<section>
<BaseSectionHeader :title="group" class="mb-2" />
<BaseCard>
<BaseListRow
v-for="c in categories"
:key="c.id"
as="button"
chevron
class="text-body text-fg"
@click="emit('select', c.id)"
>
<span class="flex-1"></span>
</BaseListRow>
</BaseCard>
</section>
</template>
<!-- src/views/CategoriesView.vue -->
<script setup lang="ts">
//
// CategoriesView — transaction categories by group. Route shell over
// `useCategoryList`.
//
// app imports
//
import BaseBanner from "@/components/base/BaseBanner.vue";
import BaseSkeleton from "@/components/base/BaseSkeleton.vue";
import CategoryGroup from "@/components/categories/CategoryGroup.vue";
import { useCategoryList } from "@/features/categories/useCategoryList";
import AppShell from "@/features/shell/AppShell.vue";
////////////////////////////////////////////////////////////////////////
//
const { groups, loading, error } = useCategoryList();
</script>
<template>
<AppShell>
<div class="space-y-section py-2">
<BaseSkeleton v-if="loading" class="h-16" />
<BaseBanner v-else-if="error" tone="danger"></BaseBanner>
<CategoryGroup
v-for="g in groups"
v-else
:key="g.group"
:group="g.group"
:categories="g.categories"
/>
</div>
</AppShell>
</template>
// src/router/types.ts -- in AppRouteNamedMap
categories: RouteRecordInfo<"categories", "/categories/", NoParams, NoParams>;
// src/router/index.ts -- in routes
{
path: "/categories/",
name: "categories",
component: () => import("@/views/CategoriesView.vue"),
meta: AUTHENTICATED,
},
Elsewhere, navigate with router.push({ name: "categories" }) or
<RouterLink :to="{ name: 'categories' }">.
// tests/views/CategoriesView.test.ts
describe("CategoriesView", () => {
beforeEach(() => {
withAuth();
withAccounts([makeBankAccount()]);
});
// GIVEN: categories in two groups
// WHEN: the categories page is opened
// THEN: each group is shown with its categories
//
it("lists categories by group", async () => {
server.use(
http.get("/api/v1/transaction-categories/", () =>
HttpResponse.json(
makePage([
makeCategory({ group: "Food", name: "Groceries" }),
makeCategory({ group: "Home", name: "Rent" }),
]),
),
),
);
const { wrapper } = await mountWithApp(CategoriesView, { route: "/categories/" });
expect(wrapper.findAll("h2").map((h) => h.text())).toEqual(["Food", "Home"]);
});
});
:idA detail page takes the id as a prop (props: true on the route) and
passes a getter to its feature composable:
const props = defineProps<{ id: string }>();
const { budget, loading, error } = useBudgetDetail(() => props.id);
When the user moves from one budget to another, Vue Router reuses the
view instance, so a value read once in setup would go stale. The
getter makes useResource reload on every new id. Other per-id state
(an open sheet, a draft) is cleared with watch(() => props.id, ...),
as BudgetDetailView.vue does.
A large view that loads its own data and mixes several sections can move onto the layers in small, separately testable steps. Each step leaves the app working and the tests green. Do them in this order:
tests/views/<View>.test.ts covering what it renders from the mock
API and what it sends on its main actions. This test must still pass
unchanged at the end.parseFloat, new Date("YYYY-MM-DD")), switch to the
model’s fields (Money, LocalDate) and the domain/ formatters.features/<section>/use<Thing>.ts. The template keeps
binding the same names, destructured from the composable. Replace
hand-rolled “ignore stale response” flags with useResource /
useAsync. Replace direct api calls for shared entities with store
actions.components/<section>/.features/<section>/ with its own composable.useModal for scroll lock, Escape and focus return;useFormErrors for DRF field errors;useInfiniteList for paging;useDebouncedAutosave for autosave fields.{ name, params }.After each step, run pnpm type-check && pnpm test. When the view no
longer imports @/api, the architecture test keeps it that way.