Clone project
git clone https://github.com/DomZem/nextjs-panel-adminGo to the project directory
cd nextjs-panel-adminInstall dependencies
npm installCreate dev db
docker compose up -dSeed dev db
npm run seed:devRun dev app
npm run dev- Feat: add saving selected columns to display in local storage
- Feat: add scroll to sheet
- Feat: add option to add default value for field through prop in auto form
- Feat: add combobox to choose relation data. Ex. adding user to order, or adding product to order_item model
- Feat: add another variant of auto table without required onDetails and renderDetails
- Feat: add wysiwg editor as input to auto form
- Feat: add component for date input
- Fix:
valueprop oninputshould not be null. Consider using an empty string to clear the component orundefinedfor uncontrolled components It appears on update product - Feat: add to each input in auto form which can be nullable button that will set the field to null
- Feat: add filters between header and table
- Fix: after refresh selected row disappear
- Feat: added columns map to auto table. By adding that i will be able to modify the end result of column. Like for example rendering user avatar
- Feat: add saving columns order in local storage for dnd table
- Fix: add clear option to filters
- Fix: fix issue with clearing values in update form for nullable inputs
- Fix: after add some fields to existing models, dragging columns is not possible
- Test: add basic tests for AutoForm in jest
- Fix: on mobile view filters takes a lot of space. We should hide them some where
- Feat: replace date-fns by dayjs in date time picker
- Test: add some tests for auto table
- Fix: fix issue with no handle discriminated unions in auto form
- Fix: clear value in auto form for textarea not working
- Feat: add lazy and eager loading for renderDetails
- Feat: add mapping labels for discriminator and description
- Feat: add clearing field for combobox
- Feat: add multiple delete
- Feat: add default selected columns
src/
├── common/
│ └── validations/
│ └── [module]/
│ └── model-name.ts # Zod schemas shared between tRPC procedures and components
│
├── components/
│ └── features/
│ └── [module]/
│ ├── model-name-[table | filters].tsx # Feature components like CRUD tables, filters, etc.
│ ├── users-table.tsx # (example) CRUD table for users
│ └── user-filters.tsx # (example) Filter UI for user module
│
├── server/
│ └── api/
│ └── routers/
│ └── [module]/
│ └── model-name.ts # tRPC router definition for the module
Create model-name.ts file inside validation folder.
For example let's create region.ts validation file for region model.
import { RegionScalarSchema } from "~/zod-schemas/models";
export const regionSchema = RegionScalarSchema;
export const regionFormSchema = regionSchema
.omit({
created_at: true,
updated_at: true,
})
.partial({
id: true,
});💡 Tip: Whenever you are creating validation for independent model use ScalarSchemaCreate model-name.ts file inside server/api/routers folder.
For example let's create region.ts router file for region model.
import { regionFormSchema } from "~/common/validations/region/region";
import { adminProcedure, createTRPCRouter } from "../../trpc";
import { RegionScalarSchema } from "~/zod-schemas/models";
export const regionRouter = createTRPCRouter({
getAll: adminProcedure.query(async ({ ctx }) => {
const regions = await ctx.db.region.findMany({
orderBy: {
id: "asc",
},
});
return regions;
}),
getOne: adminProcedure
.input(RegionScalarSchema.pick({ id: true }))
.mutation(async ({ ctx, input }) => {
const result = await ctx.db.region.findFirstOrThrow({
where: {
id: input.id,
},
});
return result;
}),
createOne: adminProcedure
.input(regionFormSchema.omit({ id: true }))
.mutation(async ({ ctx, input }) => {
const result = await ctx.db.region.create({
data: input,
});
return result;
}),
updateOne: adminProcedure
.input(regionFormSchema.required({ id: true }))
.mutation(async ({ ctx, input }) => {
const result = await ctx.db.region.update({
where: {
id: input.id,
},
data: input,
});
return result;
}),
deleteOne: adminProcedure
.input(RegionScalarSchema.pick({ id: true }))
.mutation(async ({ ctx, input }) => {
const result = await ctx.db.region.delete({
where: {
id: input.id,
},
});
return result;
}),
});Create model-name-table.tsx component file inside /components/features/[module-name] folder.
For example let's create regions-table.tsx component file inside /components/features/region.
export const RegionsTable = () => {
const getAllRegions = api.region.getAll.useQuery();
const deleteRegion = api.region.deleteOne.useMutation();
const createRegion = api.region.createOne.useMutation();
const updateRegion = api.region.updateOne.useMutation();
const getRegionDetails = api.region.getOne.useMutation();
return (
<AutoTableContainer>
<AutoTableFullActions
technicalTableName="regions"
schema={regionSchema}
rowIdentifierKey="id"
data={getAllRegions.data ?? []}
onRefetchData={getAllRegions.refetch}
onDetails={async (row) =>
await getRegionDetails.mutateAsync({
id: row.id,
})
}
onDelete={async (row) => await deleteRegion.mutateAsync({ id: row.id })}
renderDetails={(region) => {
return (
<div className="space-y-4">
<RegionCountriesTable regionId={region.id} />
</div>
);
}}
autoForm={{
formSchema: regionFormSchema,
fieldsConfig: {
id: {
hidden: true,
},
},
create: {
onCreate: createRegion.mutateAsync,
isSubmitting: createRegion.isPending,
},
update: {
onUpdate: updateRegion.mutateAsync,
isSubmitting: updateRegion.isPending,
},
}}
>
<AutoTableToolbarHeader title="Regions" />
<AutoTableDndTable
extraRow={(row) => <AutoTableDetailsRow rowId={row.id} />}
/>
</AutoTableFullActions>
</AutoTableContainer>
);
};First create model-name-combobox.tsx component file inside /components/features/[model-name].
Example:
import { FormItem, FormLabel } from "~/components/ui/form";
import { Combobox } from "~/components/ui/combobox";
import { api } from "~/trpc/react";
import { useState } from "react";
export const UserCombobox = ({
selectedValue,
onSelect,
}: {
selectedValue?: string;
onSelect: (value: string) => void;
}) => {
const [searchValue, setSearchValue] = useState("");
const getSearchUsers = api.user.getSearchUsers.useQuery({
name: searchValue,
});
const userOptions =
getSearchUsers.data?.map((user) => ({
value: user.id,
label: user.name ?? "",
})) ?? [];
return (
<FormItem>
<FormLabel>user</FormLabel>
<Combobox
options={userOptions}
onInputChange={setSearchValue}
onSelect={onSelect}
selectedValue={selectedValue}
emptyPlaceholder="No user found."
searchPlaceholder="Search user..."
selectPlaceholder="Select user..."
/>
</FormItem>
);
};getSearchUsers: adminProcedure
.input(
z.object({
name: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
if (!input.name) {
const users = await ctx.db.user.findMany({
take: 5,
orderBy: {
id: "asc",
},
select: {
id: true,
name: true,
},
});
return users;
}
const filteredUsers = await ctx.db.user.findMany({
where: {
name: {
contains: input.name,
},
},
orderBy: {
id: "asc",
},
select: {
id: true,
name: true,
},
});
return filteredUsers;
}),Then use that component as custom field type in AutoForm
user_id: {
type: "custom",
render: ({ field }) => {
return (
<UserCombobox
selectedValue={field.value}
onSelect={field.onChange}
/>
);
},
},In order to display value from nested object you have to merge field to schema and then map getAll method in router.
For example, let's say for order model you want to display which user created order.
const orderSchema = orderRawSchema.merge(
z.object({
username: z.string(),
}),
);const orders = await ctx.db.order.findMany({
where,
skip: (input.page - 1) * input.pageSize,
take: input.pageSize,
orderBy: {
id: "asc",
},
include: {
user: {
select: {
name: true,
},
},
},
});
const mappedOrders = orders.map(({ user, ...rest }) => ({
...rest,
username: user.name!,
}));In order to add custom validation for input field you have to omit that field and then merge with validation.
export const regionFormSchema = regionSchema
.omit({
created_at: true,
updated_at: true,
})
.partial({
id: true,
})
.omit({
name: true,
})
.merge(
z.object({
name: z.string().min(3).max(255),
}),
);When the field type for comobobox is not a string, but for example the number. All you have to do is:
- Allow
selectedValueprop inside model-name-combobox.tsx file to be that type.export const RegionCountryCombobox = ({ selectedValue, onSelect, }: { selectedValue?: string | number; onSelect: (value: string) => void; }) => {};
- Add
coerceto field that is using combobox as component.export const userAddressFormSchema = userAddressSchema .omit({ region_country_id: true, created_at: true, updated_at: true, }) .merge( z.object({ region_country_id: z.coerce.number(), }), ) .partial({ id: true, });