This checklist and mini-tutorial will make sure you make the most of your shiny new Bison app.
- Run
yarn setup:dev
to prep and migrate your local database, as well as generate the prisma client. If this fails, make sure you have Postgres running and the generatedDATABASE_URL
values are correct in your.env
files. - Run
yarn dev
to start your development server
While not a requirement, Bison works best when you start development with the database and API layer.
We will illustrate how to use this by adding the concept of an organization to our app.
The workflow below assumes you already have yarn dev
running.
Bison uses Prisma for database operations. We've added a few conveniences around the default Prisma setup, but if you're familiar with Prisma, you're familiar with databases in Bison.
- Define an Organization table in
prisma/schema.prisma
.
We suggest copying the id
, createdAt
and updatedAt
fields from the User
model.
model Organization {
id String @id @default(cuid())
name String
users User[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
If you use VSCode and have the Prisma extension
installed, saving the file should automatically add the inverse relationship to the User
model!
model User {
id String @id @default(cuid())
email String @unique
password String
roles Role[]
profile Profile?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization? @relation(fields: [organizationId], references: [id])
organizationId String?
}
- Generate a migration with
yarn g:migration
.
You should see a new folder in prisma/migrations
.
- Migrate the database with
yarn db:migrate
For more on Prisma, view the docs.
With the database changes complete, we need to decide what types, queries, and mutations to expose in our tRPC API.
Bison uses tRPC to create an end-to-end typed API which you can easily use throughout your app. The HTTP specification is simple enough that you can easily query the API from third-party apps as well, though any apps outside of your app's monorepo lose the benefit of end-to-end type safety.
tRPC organizes its API using routers and procedures. Here's how to create a new router.
- Create a new Router module using
yarn g:trpc organization
- Edit the new module to reflect what you want to expose via the API. Be as granular as you want with the query and mutation procedures.
We'll use zod to ensure type safety for our inputs and
the t
utility provided by tRPC to create our routers. We use the protectedProcedure
for
procedures that require users to be logged in and adminProcedure
for procedures that require
the ADMIN
role. Unprotected procedures can be added with t.procedure
.
File: ./server/routers/organization.ts
import { Prisma } from '@prisma/client';
import { z } from 'zod';
import { defaultUserSelect } from './user';
import { BisonError, t } from '@/server/trpc';
import { protectedProcedure } from '@/server/middleware/auth';
// Organization default selection
export const defaultOrganizationSelect = Prisma.validator<Prisma.OrganizationSelect>()({
id: true,
createdAt: true,
updatedAt: true,
name: true,
users: { select: defaultUserSelect },
});
export const organizationRouter = t.router({
findMany: protectedProcedure
.input(
z.object({
where: z.object({ name: z.string().optional() }).optional(),
orderBy: z
.object({ name: z.enum(['asc', 'desc']) })
.array()
.optional(),
})
)
.query(async ({ ctx, input }) => {
const { where = {}, orderBy = [] } = input;
return await ctx.db.organization.findMany({
where,
orderBy,
select: defaultOrganizationSelect,
});
}),
find: protectedProcedure
.input(z.object({ where: z.object({ id: z.string() }) }))
.query(async ({ ctx, input }) => {
const { where } = input;
return ctx.prisma.organization.findUnique({
where,
select: defaultOrganizationSelect,
});
}),
create: protectedProcedure
.input(z.object({ data: z.object({ name: z.string() }) }))
.mutation(async ({ ctx, input }) => {
const { data } = input;
return await ctx.db.organization.create({
data: { ...data, users: { connect: [{ id: ctx.user.id }] } },
select: defaultOrganizationSelect,
});
}),
update: protectedProcedure
.input(
z.object({
where: z.object({ id: z.string() }),
data: z.object({ name: z.string() }),
})
)
.mutation(async ({ ctx, input }) => {
const { where, data } = input;
const organization = await ctx.db.organization.findFirst({
where: { id: where.id, users: { some: { id: ctx.user.id } } },
});
if (!organization) {
throw new BisonError({
code: 'FORBIDDEN',
message: 'You are not allowed to edit this organization.',
});
}
return await ctx.db.organization.update({ where, data, select: defaultOrganizationSelect });
}),
});
Where are my types?
tRPC doesn't require type code generation - all of the types are inferred using the router and procedure definitions. This means you don't have to run a separate process to watch for changes to your schema to generate types.
Let's confirm the API changes using a request test. To do this:
- Generate a new factory:
yarn g:test:factory organization
- Add a default value for organization name in the
build
function. You can use any of the methods from thechance
library.
export const OrganizationFactory = {
build: (attrs: Partial<Prisma.OrganizationCreateInput> = {}) => {
return {
name: chance.company(), // <-- add this
...attrs,
};
},
// ...
- Generate a new API request test:
yarn g:test:request createOrganization
- Update the API request test to call the new mutation and ensure that we get an error if not logged in. TypeScript can help you make sure you get the right inputs for the procedures.
Here we use inline snapshots to confirm the error message content, but you can also manually assert the content.
import { UserFactory } from '../factories';
import { trpcRequest, resetDB, disconnect } from '@/tests/helpers';
beforeEach(async () => resetDB());
afterAll(async () => disconnect());
describe('create mutation', () => {
it('returns an error if not logged in', async () => {
const variables = { data: { name: 'Cool Company' } };
await expect(
trpcRequest().organization.create(variables)
).rejects.toThrowErrorMatchingInlineSnapshot(`"UNAUTHORIZED"`);
});
});
- Add a new test to confirm that the organization user is set to the current user
describe('as a user', () => {
it('sets the user to the logged in user', async () => {
const user = await UserFactory.create();
const variables = { data: { name: 'Cool Company' } };
const { name, users } = await trpcRequest(user).organization.create(variables);
expect(name).toEqual('Cool Company');
expect(users[0].id).toEqual(user.id);
});
});
Now that we have the API finished, we can move to the frontend changes.
- Create a new page to create organizations:
yarn g:page organizations/new
- Create an
OrganizationForm
component:yarn g:component OrganizationForm
- Add a simple form with a name input. See the React Hook Form docs for detailed information.
- Use tRPC to call the create organization mutation.
import { useForm } from 'react-hook-form';
interface OrganizationFormData {
name: string;
}
export function OrganizationForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<OrganizationFormData>();
const createMutation = trpc.organization.create.useMutation();
async function onSubmit(data: OrganizationFormData) {
createMutation.mutate(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name', { required: true })} />
{errors.name && <span>This field is required</span>}
<input type="submit" />
</form>
);
}
- Update the form to use Chakra components
import { Button, FormControl, FormLabel, Input } from '@chakra-ui/react';
import { useForm } from 'react-hook-form';
import { ErrorText } from './ErrorText';
export function OrganizationForm() {
// ...
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FormControl id="name">
<FormLabel htmlFor="name">Name</FormLabel>
<Input type="text" {...register('name', { required: true })} isInvalid={!!errors.name} />
<ErrorText>{errors.name && errors.name.message}</ErrorText>
</FormControl>
<Button type="submit" marginTop={8} width="full">
Submit
</Button>
</form>
);
}
- Attach the mutations loading state to the button loading state
<Button type="submit" marginTop={8} width="full" isLoading={createMutation.isLoading}>
Submit
</Button>
You should now have a fully working form that creates a new database entry on submit!
- Generate a new page:
yarn g:page "organizations/[:id]"
. This uses the dynamic page capability of Next.js. - Add a new "cell" to fetch data. While not required, it keeps things clean.
yarn g:cell Organization
- Add a prop to the cell for
organizationId
and pass the value to a tRPCuseQuery
call. - Update the
Success
component to take the proper return type of the query, but make it NonNullable. - Only render the
Success
component ifdata
is present.
import { Spinner, Text } from '@chakra-ui/react';
import { trpc, inferQueryOutput } from '@/lib/trpc';
export const Loading = () => <Spinner />;
export const Error = () => <Text>Error. See dev tools.</Text>;
export const Empty = () => <Text>No data.</Text>;
export const Success = (
organization: NonNullable<inferQueryOutput<'organization', 'find'>>
) => {
return <Text>Awesome! {organization.name}</Text>;
};
export const OrganizationCell = ({ organizationId }: { organizationId: string }) => {
const { data, isLoading, isError } = trpc.organization.find.useQuery({
where: { id: organizationId },
});
if (isLoading) return <Loading />;
if (isError) return <Error />;
if (data) return <Success {...data} />;
return <Empty />;
};
- Add the Cell to the organization page:
import Head from 'next/head';
import { Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import { OrganizationCell } from '@/cells/Organization';
function OrganizationPage() {
const router = useRouter();
const { id } = router.query;
return (
<>
<Head>
<title>An organization</title>
</Head>
<Flex direction={{ base: 'column', lg: 'row' }}>
<OrganizationCell organizationId={id} />
</Flex>
</>
);
}
export default OrganizationPage;
Outside of e2e tests, you've used just about every feature in Bison. But don't worry. We've got your back there too.
Bonus:
- View the login and logout e2e tests
asdf FEATURE A
asdf asdf asdf asdf FEATURE B
Feature C
asdf
asdf
asdf
asdf