From aee2b1303cd5da27a62fc74dbe105651c7eb322c Mon Sep 17 00:00:00 2001 From: Hugo FOYART <11079152+foyarash@users.noreply.github.com> Date: Wed, 29 May 2024 17:28:37 +0200 Subject: [PATCH 01/89] feat: add api route --- .../app/api/admin/[[...nextadmin]]/route.ts | 12 + apps/example/options.tsx | 8 +- apps/example/pageRouterOptions.tsx | 1 + .../src/components/ActionsDropdown.tsx | 4 +- packages/next-admin/src/components/Form.tsx | 104 ++++---- packages/next-admin/src/components/List.tsx | 10 +- .../next-admin/src/components/ListHeader.tsx | 7 +- .../next-admin/src/components/MainLayout.tsx | 1 + .../next-admin/src/components/NextAdmin.tsx | 8 - .../next-admin/src/context/ConfigContext.tsx | 4 + .../next-admin/src/context/FormContext.tsx | 20 +- packages/next-admin/src/handler.ts | 222 ++++++++++++++++++ packages/next-admin/src/handlers/options.ts | 26 ++ packages/next-admin/src/handlers/resources.ts | 166 +++++++++++++ packages/next-admin/src/hooks/useAction.ts | 30 ++- .../next-admin/src/hooks/useDeleteAction.ts | 33 ++- .../src/hooks/useSearchPaginatedResource.ts | 56 ++--- packages/next-admin/src/types.ts | 21 +- packages/next-admin/src/utils/permissions.ts | 11 + packages/next-admin/src/utils/props.ts | 18 +- packages/next-admin/tsconfig.json | 3 +- 21 files changed, 598 insertions(+), 167 deletions(-) create mode 100644 apps/example/app/api/admin/[[...nextadmin]]/route.ts create mode 100644 packages/next-admin/src/handler.ts create mode 100644 packages/next-admin/src/handlers/options.ts create mode 100644 packages/next-admin/src/handlers/resources.ts create mode 100644 packages/next-admin/src/utils/permissions.ts diff --git a/apps/example/app/api/admin/[[...nextadmin]]/route.ts b/apps/example/app/api/admin/[[...nextadmin]]/route.ts new file mode 100644 index 00000000..20bd2a8e --- /dev/null +++ b/apps/example/app/api/admin/[[...nextadmin]]/route.ts @@ -0,0 +1,12 @@ +import { createAppHandler } from "@premieroctet/next-admin/dist/handler"; +import schema from "@/prisma/json-schema/json-schema.json"; +import { prisma } from "@/prisma"; +import { options } from "@/options"; + +const { run } = createAppHandler({ + options, + prisma, + schema, +}); + +export { run as POST, run as GET, run as DELETE }; diff --git a/apps/example/options.tsx b/apps/example/options.tsx index 0726cbc6..9d637fd3 100644 --- a/apps/example/options.tsx +++ b/apps/example/options.tsx @@ -3,6 +3,7 @@ import DatePicker from "./components/DatePicker"; export const options: NextAdminOptions = { basePath: "/admin", + apiBasePath: "/api/admin", title: "⚡️ My Admin", model: { User: { @@ -136,11 +137,10 @@ export const options: NextAdminOptions = { }, actions: [ { + id: "submit-email", title: "actions.user.email.title", - action: async (...args) => { - "use server"; - const { submitEmail } = await import("./actions/nextadmin"); - await submitEmail(...args); + action: async (ids) => { + console.log("Sending email to " + ids.length + " users"); }, successMessage: "actions.user.email.success", errorMessage: "actions.user.email.error", diff --git a/apps/example/pageRouterOptions.tsx b/apps/example/pageRouterOptions.tsx index 27342e32..569da079 100644 --- a/apps/example/pageRouterOptions.tsx +++ b/apps/example/pageRouterOptions.tsx @@ -3,6 +3,7 @@ import DatePicker from "./components/DatePicker"; export const options: NextAdminOptions = { basePath: "/pagerouter/admin", + apiBasePath: "/api/admin", title: "⚡️ My Admin", model: { User: { diff --git a/packages/next-admin/src/components/ActionsDropdown.tsx b/packages/next-admin/src/components/ActionsDropdown.tsx index 4f6d5d39..6846c0c4 100644 --- a/packages/next-admin/src/components/ActionsDropdown.tsx +++ b/packages/next-admin/src/components/ActionsDropdown.tsx @@ -17,7 +17,7 @@ import { } from "./radix/Dropdown"; type Props = { - actions: ModelAction[]; + actions: Array>; selectedIds: string[] | number[]; resource: ModelName; selectedCount?: number; @@ -33,7 +33,7 @@ const ActionsDropdown = ({ const { t } = useI18n(); const [isOpen, setIsOpen] = useState(false); - const onActionClick = (action: ModelAction) => { + const onActionClick = (action: ModelAction | Omit) => { runAction(action); }; diff --git a/packages/next-admin/src/components/Form.tsx b/packages/next-admin/src/components/Form.tsx index ff9f14f7..05c4198a 100644 --- a/packages/next-admin/src/components/Form.tsx +++ b/packages/next-admin/src/components/Form.tsx @@ -18,14 +18,17 @@ import { import validator from "@rjsf/validator-ajv8"; import clsx from "clsx"; import dynamic from "next/dynamic"; +import Link from "next/link"; import React, { ChangeEvent, + HTMLProps, cloneElement, + forwardRef, + useCallback, useEffect, useMemo, useRef, useState, - useTransition, } from "react"; import { useConfig } from "../context/ConfigContext"; import { FormContext, FormProvider } from "../context/FormContext"; @@ -36,7 +39,6 @@ import { AdminComponentProps, EditFieldsOptions, Field, - ModelAction, ModelIcon, ModelName, ModelOptions, @@ -65,16 +67,12 @@ import { TooltipRoot, TooltipTrigger, } from "./radix/Tooltip"; -import Link from "next/link"; +import { useDeleteAction } from "../hooks/useDeleteAction"; const RichTextField = dynamic(() => import("./inputs/RichText/RichTextField"), { ssr: false, }); -class CustomForm extends RjsfForm { - onSubmit = (e: any) => {}; -} - export type FormProps = { data: any; schema: any; @@ -82,21 +80,19 @@ export type FormProps = { resource: ModelName; slug?: string; validation?: PropertyValidationError[]; - action?: (formData: FormData) => Promise; title: string; customInputs?: Record, React.ReactElement | undefined>; - actions?: ModelAction[]; - searchPaginatedResourceAction?: AdminComponentProps["searchPaginatedResourceAction"]; + actions?: AdminComponentProps["actions"]; icon?: ModelIcon; resourcesIdProperty: Record; }; -const fields: CustomForm["props"]["fields"] = { +const fields: RjsfForm["props"]["fields"] = { ArrayField, NullField, }; -const widgets: CustomForm["props"]["widgets"] = { +const widgets: RjsfForm["props"]["widgets"] = { DateWidget: DateWidget, DateTimeWidget: DateTimeWidget, SelectWidget: SelectWidget, @@ -112,16 +108,14 @@ const Form = ({ resource, slug, validation: validationProp, - action, title, customInputs, actions, - searchPaginatedResourceAction, icon, resourcesIdProperty, }: FormProps) => { const [validation, setValidation] = useState(validationProp); - const { basePath, options } = useConfig(); + const { basePath, options, apiBasePath } = useConfig(); const modelOptions: ModelOptions[typeof resource] = options?.model?.[resource]; const canDelete = @@ -141,9 +135,10 @@ const Form = ({ ); const { router } = useRouterInternal(); const { t } = useI18n(); - const formRef = useRef(null); - const [isPending, startTransition] = useTransition(); + const formRef = useRef(null); + const [isPending, setIsPending] = useState(false); const allDisabled = edit && !canEdit; + const { runDeletion } = useDeleteAction(resource); useEffect(() => { if (!edit && !canCreate) { @@ -196,9 +191,16 @@ const Form = ({ value="delete" formNoValidate tabIndex={-1} - onClick={(e) => { + onClick={async (e) => { if (!confirm(t("form.delete.alert"))) { e.preventDefault(); + + try { + setIsPending(true); + await runDeletion([id!] as string[] | number[]); + } finally { + setIsPending(false); + } } }} type="submit" @@ -225,9 +227,17 @@ const Form = ({ {} as ErrorSchema ); - const onSubmit = async (formData: FormData) => { - if (action) { - const result = await action(formData); + const onSubmit = useCallback( + async (formData: FormData) => { + const response = await fetch( + `${apiBasePath}/${slugify(resource)}${id ? `/${id}` : ""}`, + { + method: "POST", + body: formData, + } + ); + + const result = await response.json(); if (result?.validation) { setValidation(result.validation); @@ -248,9 +258,7 @@ const Form = ({ } if (result?.created) { - const pathname = result?.redirect - ? `${basePath}/${slugify(resource)}` - : `${basePath}/${slugify(resource)}/${result.createdId}`; + const pathname = `${basePath}/${slugify(resource)}/${result.createdId}`; return router.replace({ pathname, query: { @@ -285,16 +293,38 @@ const Form = ({ }, }); } - } - }; + }, + [apiBasePath, id] + ); - const onSubmitAction = (formData: FormData) => { - startTransition(() => { - onSubmit(formData); - }); - }; + const onSubmitAction = useCallback( + async (formData: FormData) => { + try { + setIsPending(true); + await onSubmit(formData); + } finally { + setIsPending(false); + } + }, + [onSubmit] + ); + + const CustomForm = useMemo(() => { + return forwardRef>( + (props, ref) => ( +
{ + e.preventDefault(); + onSubmitAction(new FormData(e.target as HTMLFormElement)); + }} + /> + ) + ); + }, [onSubmitAction]); - const templates: CustomForm["props"]["templates"] = useMemo( + const templates: RjsfForm["props"]["templates"] = useMemo( () => ({ FieldTemplate: (props: FieldTemplateProps) => { const { @@ -522,7 +552,6 @@ const Form = ({
{({ formData, setFormData }) => ( - { setFormData(e.formData); }} idPrefix="" idSeparator="" - enctype={!action ? "multipart/form-data" : undefined} {...schemas} formData={formData} validator={validator} @@ -552,8 +578,6 @@ const Form = ({ ButtonTemplates: { SubmitButton: submitButton }, }} widgets={widgets} - onSubmit={(e) => console.log("onSubmit", e)} - onError={(e) => console.log("onError", e)} ref={formRef} /> )} diff --git a/packages/next-admin/src/components/List.tsx b/packages/next-admin/src/components/List.tsx index d74b33b3..c0db66dd 100644 --- a/packages/next-admin/src/components/List.tsx +++ b/packages/next-admin/src/components/List.tsx @@ -10,9 +10,9 @@ import useDataColumns from "../hooks/useDataColumns"; import { useDeleteAction } from "../hooks/useDeleteAction"; import { useRouterInternal } from "../hooks/useRouterInternal"; import { + AdminComponentProps, ListData, ListDataItem, - ModelAction, ModelIcon, ModelName, } from "../types"; @@ -45,8 +45,7 @@ export type ListProps = { total: number; resourcesIdProperty: Record; title: string; - actions?: ModelAction[]; - deleteAction?: ModelAction["action"]; + actions?: AdminComponentProps["actions"]; icon?: ModelIcon; }; @@ -57,18 +56,17 @@ function List({ actions, resourcesIdProperty, title, - deleteAction, icon, }: ListProps) { const { router, query } = useRouterInternal(); const [isPending, startTransition] = useTransition(); - const { isAppDir, options } = useConfig(); + const { options } = useConfig(); const [rowSelection, setRowSelection] = useState({}); const pageIndex = typeof query.page === "string" ? Number(query.page) - 1 : 0; const pageSize = Number(query.itemsPerPage) || (ITEMS_PER_PAGE as number); const sortColumn = query.sortColumn as string; const sortDirection = query.sortDirection as "asc" | "desc"; - const { deleteItems } = useDeleteAction(resource, deleteAction); + const { deleteItems } = useDeleteAction(resource); const { t } = useI18n(); const columns = useDataColumns({ data, diff --git a/packages/next-admin/src/components/ListHeader.tsx b/packages/next-admin/src/components/ListHeader.tsx index 54260730..66d18cbc 100644 --- a/packages/next-admin/src/components/ListHeader.tsx +++ b/packages/next-admin/src/components/ListHeader.tsx @@ -11,6 +11,7 @@ import Loader from "../assets/icons/Loader"; import { useConfig } from "../context/ConfigContext"; import { useI18n } from "../context/I18nContext"; import { + AdminComponentProps, ModelAction, ModelIcon, ModelName, @@ -22,13 +23,14 @@ import ActionsDropdown from "./ActionsDropdown"; import Breadcrumb from "./Breadcrumb"; import ExportDropdown from "./ExportDropdown"; import { buttonVariants } from "./radix/Button"; +import { SPECIFIC_IDS_TO_RUN_ACTION } from "../hooks/useAction"; type Props = { resource: ModelName; isPending: boolean; onSearchChange?: (e: ChangeEvent) => void; search: string; - actions?: ModelAction[]; + actions?: AdminComponentProps["actions"]; selectedRows: RowSelectionState; getSelectedRowsIds: () => string[] | number[]; onDelete: () => Promise; @@ -65,10 +67,11 @@ export default function ListHeader({ const selectedRowsCount = Object.keys(selectedRows).length; - const actions = useMemo(() => { + const actions = useMemo(() => { const defaultActions: ModelAction[] = canDelete ? [ { + id: SPECIFIC_IDS_TO_RUN_ACTION.DELETE, title: t("actions.delete.label"), style: "destructive", action: async () => { diff --git a/packages/next-admin/src/components/MainLayout.tsx b/packages/next-admin/src/components/MainLayout.tsx index ceb09d51..96985a1c 100644 --- a/packages/next-admin/src/components/MainLayout.tsx +++ b/packages/next-admin/src/components/MainLayout.tsx @@ -34,6 +34,7 @@ export const MainLayout = ({ options={options} basePath={`${localePath}${basePath}`} isAppDir={isAppDir} + apiBasePath={options!.apiBasePath} > diff --git a/packages/next-admin/src/components/NextAdmin.tsx b/packages/next-admin/src/components/NextAdmin.tsx index ce2f05a3..66079b53 100644 --- a/packages/next-admin/src/components/NextAdmin.tsx +++ b/packages/next-admin/src/components/NextAdmin.tsx @@ -16,24 +16,19 @@ export function NextAdmin({ schema, resources, slug, - message, - error, total, dmmfSchema, dashboard, validation, isAppDir, - action, options, resourcesTitles, resourcesIdProperty, customInputs: customInputsProp, customPages, actions: actionsProp, - deleteAction, translations, locale, - searchPaginatedResourceAction, title, sidebar, resourcesIcons, @@ -66,7 +61,6 @@ export function NextAdmin({ title={resourceTitle!} resourcesIdProperty={resourcesIdProperty!} actions={actions} - deleteAction={deleteAction} icon={resourceIcon} /> ); @@ -85,11 +79,9 @@ export function NextAdmin({ dmmfSchema={dmmfSchema!} resource={resource!} validation={validation} - action={action} title={resourceTitle!} customInputs={customInputs} actions={actions} - searchPaginatedResourceAction={searchPaginatedResourceAction} icon={resourceIcon} resourcesIdProperty={resourcesIdProperty!} /> diff --git a/packages/next-admin/src/context/ConfigContext.tsx b/packages/next-admin/src/context/ConfigContext.tsx index b0ed1079..e00e8b42 100644 --- a/packages/next-admin/src/context/ConfigContext.tsx +++ b/packages/next-admin/src/context/ConfigContext.tsx @@ -6,6 +6,7 @@ export type ConfigContextType = { options?: NextAdminOptions; basePath: string; isAppDir: boolean; + apiBasePath: string; }; const ConfigContext = React.createContext( @@ -14,6 +15,7 @@ const ConfigContext = React.createContext( type ProviderProps = { basePath: string; + apiBasePath: string; options?: NextAdminOptions; children: React.ReactNode; isAppDir?: boolean; @@ -24,6 +26,7 @@ export const ConfigProvider = ({ options, basePath, isAppDir = false, + apiBasePath, }: ProviderProps) => { return ( {children} diff --git a/packages/next-admin/src/context/FormContext.tsx b/packages/next-admin/src/context/FormContext.tsx index 3fee36cb..43557f7a 100644 --- a/packages/next-admin/src/context/FormContext.tsx +++ b/packages/next-admin/src/context/FormContext.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useState, } from "react"; -import { AdminComponentProps, ModelName, NextAdminOptions } from "../types"; +import { ModelName, NextAdminOptions } from "../types"; import { Prisma } from "@prisma/client"; type FormContextType = { @@ -22,9 +22,7 @@ type FormContextType = { }; setOpen: (open: boolean, name: string) => void; toggleOpen: (name: string) => void; - setSelectedValue: (selectedValue: any, name: string) => void; resource?: ModelName; - searchPaginatedResourceAction?: AdminComponentProps["searchPaginatedResourceAction"]; dmmfSchema?: readonly Prisma.DMMF.Field[]; options?: NextAdminOptions; resourcesIdProperty: Record | null; @@ -35,14 +33,12 @@ export const FormContext = createContext({ setFormData: (_formData: any) => {}, relationState: {}, setOpen: (_open: boolean, _name: string) => {}, - setSelectedValue: (_selectedValue: any, _name: string) => {}, toggleOpen: (_name: string) => {}, resourcesIdProperty: null, }); type Props = PropsWithChildren<{ initialValue: any; - searchPaginatedResourceAction?: AdminComponentProps["searchPaginatedResourceAction"]; dmmfSchema: readonly Prisma.DMMF.Field[]; resource: ModelName; options?: NextAdminOptions; @@ -61,7 +57,6 @@ type RelationState = { export const FormProvider = ({ children, initialValue, - searchPaginatedResourceAction, resource, dmmfSchema, options, @@ -105,17 +100,6 @@ export const FormProvider = ({ })); }; - const setSelectedValue = (selectedValue: any, name: string) => { - if (!relationState) return; - setRelationState((relationState) => ({ - ...relationState, - [name]: { - ...relationState[name], - selectedValue, - }, - })); - }; - return ( = { + params: Record; +}; + +type CreateAppHandlerParams

= { + /** + * Next-admin options + */ + options: NextAdminOptions; + /** + * Prisma client instance + */ + prisma: PrismaClient; + /** + * A function that acts as a middleware. Useful to add authentication logic for example. + */ + onRequest?: ( + req: NextRequest, + ctx: RequestContext

, + next: NextHandler + ) => + | ReturnType + | ReturnType + | Promise; + /** + * A string indicating the name of the dynamic segment. + * + * Example: + * - If the dynamic segment is `[[...nextadmin]]`, then the `paramKey` should be `nextadmin`. + * - If the dynamic segment is `[[...admin]]`, then the `paramKey` should be `admin`. + * + * @default "nextadmin" + */ + paramKey?: P; + /** + * Generated JSON schema from Prisma + */ + schema: any; +}; + +export const createAppHandler =

({ + options, + prisma, + paramKey = "nextadmin" as P, + onRequest, + schema, +}: CreateAppHandlerParams

) => { + const router = createEdgeRouter>(); + const resources = getResources(options); + + if (onRequest) { + router.use(async (req, ctx, next) => { + const response = await onRequest(req, ctx, next); + + if (response) { + return response; + } + + return next(); + }); + } + + router + .post(`${options.apiBasePath}/:model/actions/:id`, async (req, ctx) => { + const id = ctx.params[paramKey].at(-1)!; + + // Make sure we don't have a false positive with a model that could be named actions + const resource = getResourceFromParams( + [ctx.params[paramKey][0]], + resources + ); + + if (!resource) { + return NextResponse.json( + { error: "Resource not found" }, + { status: 404 } + ); + } + + const modelAction = options.model?.[resource]?.actions?.find( + (action) => action.id === id + ); + + if (!modelAction) { + return NextResponse.json( + { error: "Action not found" }, + { status: 404 } + ); + } + + const body = await req.json(); + + try { + await modelAction.action(body as string[] | number[]); + + return NextResponse.json({ ok: true }); + } catch (e) { + return NextResponse.json( + { error: (e as Error).message }, + { status: 500 } + ); + } + }) + .post(`${options.apiBasePath}/options`, async (req, ctx) => { + const body = await req.json(); + const data = await handleOptionsSearch(body, prisma, options); + + return NextResponse.json(data); + }) + .post(`${options.apiBasePath}/:model/:id?`, async (req, ctx) => { + const resource = getResourceFromParams(ctx.params[paramKey], resources); + + if (!resource) { + return NextResponse.json( + { error: "Resource not found" }, + { status: 404 } + ); + } + + const body = await getFormValuesFromFormData(await req.formData()); + const id = + ctx.params[paramKey].length === 2 + ? formatId(resource, ctx.params[paramKey].at(-1)!) + : undefined; + + try { + const response = await submitResource({ + prisma, + resource, + body, + id, + options, + schema, + }); + + if (response.error) { + return NextResponse.json( + { error: response.error, validation: response.validation }, + { status: 400 } + ); + } + + return NextResponse.json(response, { status: id ? 200 : 201 }); + } catch (e) { + return NextResponse.json( + { error: (e as Error).message }, + { status: 500 } + ); + } + }) + .delete(`${options.apiBasePath}/:model/:id`, async (req, ctx) => { + const resource = getResourceFromParams(ctx.params[paramKey], resources); + + if (!resource) { + return NextResponse.json( + { error: "Resource not found" }, + { status: 404 } + ); + } + + if (hasPermission(options.model?.[resource], Permission.DELETE)) { + return NextResponse.json( + { error: "You don't have permission to delete this resource" }, + { status: 403 } + ); + } + + await deleteResource({ + body: [ctx.params[paramKey][1]], + prisma, + resource, + }); + + return NextResponse.json({ ok: true }); + }) + .delete(`${options.apiBasePath}/:model`, async (req, ctx) => { + const resource = getResourceFromParams(ctx.params[paramKey], resources); + + if (!resource) { + return NextResponse.json( + { error: "Resource not found" }, + { status: 404 } + ); + } + + if (hasPermission(options.model?.[resource], Permission.DELETE)) { + return NextResponse.json( + { error: "You don't have permission to delete this resource" }, + { status: 403 } + ); + } + + const body = await req.json(); + + await deleteResource({ body, prisma, resource }); + + return NextResponse.json({ ok: true }); + }); + + const executeRouteHandler = ( + req: NextRequest, + context: RequestContext

+ ) => { + return router.run(req, context); + }; + + return { run: executeRouteHandler, router }; +}; diff --git a/packages/next-admin/src/handlers/options.ts b/packages/next-admin/src/handlers/options.ts new file mode 100644 index 00000000..65f9661e --- /dev/null +++ b/packages/next-admin/src/handlers/options.ts @@ -0,0 +1,26 @@ +import { PrismaClient } from "@prisma/client"; +import { optionsFromResource } from "../utils/prisma"; +import { NextAdminOptions } from "../types"; + +export const handleOptionsSearch = ( + body: any, + prisma: PrismaClient, + options: NextAdminOptions +) => { + const { originModel, property, model, query, page, perPage } = body; + + return optionsFromResource({ + prisma, + originResource: originModel, + property: property, + resource: model, + options, + context: {}, + searchParams: new URLSearchParams({ + search: query, + page: page.toString(), + itemsPerPage: perPage.toString(), + }), + appDir: false, + }); +}; diff --git a/packages/next-admin/src/handlers/resources.ts b/packages/next-admin/src/handlers/resources.ts new file mode 100644 index 00000000..b9574e7d --- /dev/null +++ b/packages/next-admin/src/handlers/resources.ts @@ -0,0 +1,166 @@ +import { PrismaClient } from "@prisma/client"; +import { + EditFieldsOptions, + ModelName, + NextAdminOptions, + Permission, +} from "../types"; +import { + formattedFormData, + getModelIdProperty, + getPrismaModelForResource, + parseFormData, +} from "../utils/server"; +import { uncapitalize } from "../utils/tools"; +import { validate } from "../utils/validator"; +import { hasPermission } from "../utils/permissions"; +import { + PrismaClientKnownRequestError, + PrismaClientValidationError, +} from "@prisma/client/runtime/library"; + +type DeleteResourceParams = { + prisma: PrismaClient; + resource: ModelName; + body: string[] | number[]; +}; + +export const deleteResource = ({ + prisma, + resource, + body, +}: DeleteResourceParams) => { + const modelIdProperty = getModelIdProperty(resource); + + // @ts-expect-error + return prisma[uncapitalize(resource)].deleteMany({ + where: { + [modelIdProperty]: { + in: body, + }, + }, + }); +}; + +type SubmitResourceParams = { + prisma: PrismaClient; + resource: ModelName; + body: Record; + id?: string | number; + options: NextAdminOptions; + schema: any; +}; + +export const submitResource = async ({ + prisma, + resource, + body, + id, + options, + schema, +}: SubmitResourceParams) => { + const { __admin_redirect: redirect, ...formValues } = body; + + const dmmfSchema = getPrismaModelForResource(resource); + const parsedFormData = parseFormData(formValues, dmmfSchema?.fields!); + const resourceIdField = getModelIdProperty(resource); + + let data; + + const fields = options.model?.[resource]?.edit?.fields as EditFieldsOptions< + typeof resource + >; + + try { + validate(parsedFormData, fields); + + const { formattedData, complementaryFormattedData, errors } = + await formattedFormData( + formValues, + dmmfSchema?.fields!, + schema, + resource, + id, + fields + ); + + if (errors.length) { + return { + error: + options.model?.[resource]?.edit?.submissionErrorMessage ?? + "Submission error", + validation: errors.map((error) => ({ + property: error.field, + message: error.message, + })), + }; + } + + // Edit + if (!!id) { + if (!hasPermission(options.model?.[resource], Permission.EDIT)) { + return { + error: "Unable to update items of this model", + }; + } + + // @ts-expect-error + data = await prisma[resource].update({ + where: { + [resourceIdField]: id, + }, + data: formattedData, + }); + + return { updated: true, redirect: redirect === "list" }; + } + + if (!hasPermission(options.model?.[resource], Permission.CREATE)) { + return { + error: "Unable to create items of this model", + }; + } + + // @ts-expect-error + data = await prisma[resource].create({ + data: formattedData, + }); + + // @ts-expect-error + await prisma[resource].update({ + where: { + [resourceIdField]: data[resourceIdField], + }, + data: complementaryFormattedData, + }); + + return { + created: true, + createdId: data[resourceIdField], + redirect: redirect === "list", + }; + } catch (error: any) { + if ( + error.constructor.name === PrismaClientValidationError.name || + error.constructor.name === PrismaClientKnownRequestError.name || + error.name === "ValidationError" + ) { + let data = parsedFormData; + + // TODO This could be improved by merging form values but it's breaking stuff + if (error.name === "ValidationError") { + error.errors.map((error: any) => { + // @ts-expect-error + data[error.property] = formValues[error.property]; + }); + } + + return { + error: error.message, + validation: error.errors, + }; + } + + throw error; + } +}; diff --git a/packages/next-admin/src/hooks/useAction.ts b/packages/next-admin/src/hooks/useAction.ts index 08f2f2a4..18ece5d7 100644 --- a/packages/next-admin/src/hooks/useAction.ts +++ b/packages/next-admin/src/hooks/useAction.ts @@ -1,14 +1,40 @@ import { useRouterInternal } from "./useRouterInternal"; import { ModelAction, ModelName } from "../types"; import { useI18n } from "../context/I18nContext"; +import { useConfig } from "../context/ConfigContext"; + +export const SPECIFIC_IDS_TO_RUN_ACTION = { + DELETE: "__admin-delete", +}; export const useAction = (resource: ModelName, ids: string[] | number[]) => { const { router } = useRouterInternal(); const { t } = useI18n(); + const { apiBasePath } = useConfig(); - const runAction = async (modelAction: ModelAction) => { + const runAction = async ( + modelAction: ModelAction | Omit + ) => { try { - await modelAction.action(resource, ids); + if ( + Object.values(SPECIFIC_IDS_TO_RUN_ACTION).includes(modelAction.id) && + "action" in modelAction + ) { + await modelAction.action(ids); + } else { + const response = await fetch( + `${apiBasePath}/${resource}/actions/${modelAction.id}`, + { + method: "POST", + body: JSON.stringify(ids), + } + ); + + if (!response.ok) { + throw new Error(); + } + } + if (modelAction.successMessage) { router.setQuery( { diff --git a/packages/next-admin/src/hooks/useDeleteAction.ts b/packages/next-admin/src/hooks/useDeleteAction.ts index 661ee4d2..bf3016bd 100644 --- a/packages/next-admin/src/hooks/useDeleteAction.ts +++ b/packages/next-admin/src/hooks/useDeleteAction.ts @@ -4,31 +4,28 @@ import { ModelAction, ModelName } from "../types"; import { slugify } from "../utils/tools"; import { useRouterInternal } from "./useRouterInternal"; -export const useDeleteAction = ( - resource: ModelName, - action?: ModelAction["action"] -) => { - const { isAppDir, basePath } = useConfig(); +export const useDeleteAction = (resource: ModelName) => { + const { apiBasePath } = useConfig(); const { router } = useRouterInternal(); const { t } = useI18n(); + const runDeletion = async (ids: string[] | number[]) => { + const response = await fetch(`${apiBasePath}/${slugify(resource)}`, { + method: "DELETE", + body: JSON.stringify(ids), + }); + + if (!response.ok) { + throw new Error(); + } + }; + const deleteItems = async (ids: string[] | number[]) => { if ( window.confirm(t("list.row.actions.delete.alert", { count: ids.length })) ) { try { - if (isAppDir) { - await action?.(resource, ids); - } else { - const response = await fetch(`${basePath}/${slugify(resource)}`, { - method: "DELETE", - body: JSON.stringify(ids), - }); - - if (!response.ok) { - throw new Error(); - } - } + await runDeletion(ids); router.setQuery( { message: JSON.stringify({ @@ -49,5 +46,5 @@ export const useDeleteAction = ( } }; - return { deleteItems }; + return { deleteItems, runDeletion }; }; diff --git a/packages/next-admin/src/hooks/useSearchPaginatedResource.ts b/packages/next-admin/src/hooks/useSearchPaginatedResource.ts index a5c2d58a..840ef76d 100644 --- a/packages/next-admin/src/hooks/useSearchPaginatedResource.ts +++ b/packages/next-admin/src/hooks/useSearchPaginatedResource.ts @@ -13,8 +13,8 @@ const useSearchPaginatedResource = ({ initialOptions, }: UseSearchPaginatedResourceParams) => { const [isPending, setIsPending] = useState(false); - const { dmmfSchema, searchPaginatedResourceAction, resource } = useForm(); - const { isAppDir, basePath, options } = useConfig(); + const { dmmfSchema, resource } = useForm(); + const { apiBasePath } = useConfig(); const searchPage = useRef(1); const totalSearchedItems = useRef(0); const [allOptions, setAllOptions] = useState( @@ -35,56 +35,34 @@ const useSearchPaginatedResource = ({ try { setIsPending(true); - if (isAppDir) { - const response = await searchPaginatedResourceAction?.({ + const response = await fetch(`${apiBasePath}/options`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ originModel: resource!, property: fieldName, model, query, page: searchPage.current, perPage, - }); + }), + }); - if (response && !response.error) { - totalSearchedItems.current = response.total; + if (response.ok) { + const responseJson = await response.json(); + + if (!responseJson.error) { + totalSearchedItems.current = responseJson.total; setAllOptions((old) => { if (resetOptions) { - return response.data; + return responseJson.data; } - return [...old, ...response.data] as Enumeration[]; + return [...old, ...responseJson.data]; }); } - } else { - const response = await fetch(`${basePath}/api/options`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - originModel: resource!, - property: fieldName, - model, - query, - page: searchPage.current, - perPage, - }), - }); - - if (response.ok) { - const responseJson = await response.json(); - - if (!responseJson.error) { - totalSearchedItems.current = responseJson.total; - setAllOptions((old) => { - if (resetOptions) { - return responseJson.data; - } - - return [...old, ...responseJson.data]; - }); - } - } } } finally { setIsPending(false); diff --git a/packages/next-admin/src/types.ts b/packages/next-admin/src/types.ts index 2b3ce357..bc14acc4 100644 --- a/packages/next-admin/src/types.ts +++ b/packages/next-admin/src/types.ts @@ -274,14 +274,13 @@ export type ListExport = { /** * a string defining the format of the export. It is mandatory. */ - format: string + format: string; /** * a string defining the URL of the export. It is mandatory. */ url: string; }; - export type ListOptions = { /** * an url to export the list data as CSV. @@ -358,7 +357,8 @@ export type ActionStyle = "default" | "destructive"; export type ModelAction = { title: string; - action: (resource: ModelName, ids: string[] | number[]) => Promise; + id: string; + action: (ids: string[] | number[]) => Promise; style?: ActionStyle; successMessage?: string; errorMessage?: string; @@ -442,6 +442,10 @@ export type NextAdminOptions = { * `basePath` is a string that represents the base path of your admin. (e.g. `/admin`) - optional. */ basePath: string; + /** + * `apiBasePath` is a string that represents the base path of the admin API route. (e.g. `/api`) - optional. + */ + apiBasePath: string; /** * Global admin title * @@ -610,7 +614,6 @@ export type AdminComponentProps = { dmmfSchema?: readonly Prisma.DMMF.Field[]; isAppDir?: boolean; locale?: string; - action?: (formData: FormData) => Promise; /** * Mandatory for page router */ @@ -624,16 +627,8 @@ export type AdminComponentProps = { */ pageComponent?: React.ComponentType; customPages?: Array<{ title: string; path: string; icon?: ModelIcon }>; - actions?: ModelAction[]; - deleteAction?: (model: ModelName, ids: string[] | number[]) => Promise; + actions?: Omit[]; translations?: Translations; - searchPaginatedResourceAction?: ( - params: SearchPaginatedResourceParams - ) => Promise<{ - data: Enumeration[]; - total: number; - error: string | null; - }>; /** * Global admin title * diff --git a/packages/next-admin/src/utils/permissions.ts b/packages/next-admin/src/utils/permissions.ts new file mode 100644 index 00000000..7e83cd78 --- /dev/null +++ b/packages/next-admin/src/utils/permissions.ts @@ -0,0 +1,11 @@ +import { ModelName, ModelOptions, Permission } from "../types"; + +export const hasPermission = ( + modelOptions: ModelOptions[ModelName], + permission: Permission +) => { + return ( + !modelOptions?.permissions || + modelOptions?.permissions?.includes(permission) + ); +}; diff --git a/packages/next-admin/src/utils/props.ts b/packages/next-admin/src/utils/props.ts index e63aabb0..5ca28278 100644 --- a/packages/next-admin/src/utils/props.ts +++ b/packages/next-admin/src/utils/props.ts @@ -13,12 +13,10 @@ import { NextAdminOptions, SubmitFormResult, } from "../types"; -import { createBoundServerAction } from "./actions"; import { getCustomInputs } from "./options"; import { getMappedDataList, mapDataList, - includeOrderByPayloadForModel, selectPayloadForModel, } from "./prisma"; import { @@ -127,20 +125,10 @@ export async function getPropsFromParams({ resources, basePath, isAppDir, - action: action - ? createBoundServerAction({ schema, params }, action) - : undefined, customPages, resourcesTitles, resourcesIdProperty, - deleteAction, options: clientOptions, - searchPaginatedResourceAction: searchPaginatedResourceAction - ? createBoundServerAction( - { schema, params }, - searchPaginatedResourceAction - ) - : undefined, title, sidebar, resourcesIcons, @@ -151,7 +139,11 @@ export async function getPropsFromParams({ if (!resource) return defaultProps; - const actions = options?.model?.[resource]?.actions; + // We don't need to pass the action function to the component + const actions = options?.model?.[resource]?.actions?.map((action) => { + const { action: _, ...actionRest } = action; + return actionRest; + }); if (getMessages) { const messages = await getMessages(); diff --git a/packages/next-admin/tsconfig.json b/packages/next-admin/tsconfig.json index d0072ac5..96adeeb5 100644 --- a/packages/next-admin/tsconfig.json +++ b/packages/next-admin/tsconfig.json @@ -7,7 +7,8 @@ "./src/actions/index.ts", "./src/mainLayout.tsx", "./src/plugin.ts", - "./src/preset.ts" + "./src/preset.ts", + "./src/handler.ts" ], "exclude": ["dist", "build", "node_modules"], "compilerOptions": { From f2c6c1460b72dfc43be73510b4ece136701dba0c Mon Sep 17 00:00:00 2001 From: Hugo FOYART <11079152+foyarash@users.noreply.github.com> Date: Thu, 30 May 2024 13:45:51 +0200 Subject: [PATCH 02/89] handle page router --- apps/example/pageRouterOptions.tsx | 14 +- .../api/pagerouter/admin/[[...nextadmin]].ts | 18 + packages/next-admin/src/apiRoute.ts | 218 ++++++++++++ packages/next-admin/src/components/Form.tsx | 76 ++-- packages/next-admin/src/components/List.tsx | 11 +- .../next-admin/src/components/Message.tsx | 49 +-- .../next-admin/src/context/MessageContext.tsx | 57 +++ packages/next-admin/src/handler.ts | 11 +- packages/next-admin/src/hooks/useAction.ts | 30 +- .../next-admin/src/hooks/useDeleteAction.ts | 34 +- packages/next-admin/src/router.tsx | 327 +----------------- packages/next-admin/src/utils/server.ts | 24 +- packages/next-admin/tsconfig.json | 3 +- 13 files changed, 428 insertions(+), 444 deletions(-) create mode 100644 apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts create mode 100644 packages/next-admin/src/apiRoute.ts create mode 100644 packages/next-admin/src/context/MessageContext.tsx diff --git a/apps/example/pageRouterOptions.tsx b/apps/example/pageRouterOptions.tsx index 569da079..02aabb5f 100644 --- a/apps/example/pageRouterOptions.tsx +++ b/apps/example/pageRouterOptions.tsx @@ -3,7 +3,7 @@ import DatePicker from "./components/DatePicker"; export const options: NextAdminOptions = { basePath: "/pagerouter/admin", - apiBasePath: "/api/admin", + apiBasePath: "/api/pagerouter/admin", title: "⚡️ My Admin", model: { User: { @@ -100,15 +100,9 @@ export const options: NextAdminOptions = { actions: [ { title: "Send email", - action: async (model, ids) => { - const response = await fetch("/api/email", { - method: "POST", - body: JSON.stringify(ids), - }); - - if (!response.ok) { - throw new Error("Failed to send email"); - } + id: "submit-email", + action: async (ids) => { + console.log("Sending email to " + ids.length + " users"); }, successMessage: "Email sent successfully", errorMessage: "Error while sending email", diff --git a/apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts b/apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts new file mode 100644 index 00000000..5504a872 --- /dev/null +++ b/apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts @@ -0,0 +1,18 @@ +import { options } from "@/pageRouterOptions"; +import { prisma } from "@/prisma"; +import { createApiRouter } from "@premieroctet/next-admin/dist/apiRoute"; +import schema from "@/prisma/json-schema/json-schema.json"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +const { run } = createApiRouter({ + options, + prisma, + schema: schema, +}); + +export default run; diff --git a/packages/next-admin/src/apiRoute.ts b/packages/next-admin/src/apiRoute.ts new file mode 100644 index 00000000..01534525 --- /dev/null +++ b/packages/next-admin/src/apiRoute.ts @@ -0,0 +1,218 @@ +import { PrismaClient } from "@prisma/client"; +import { NextApiRequest, NextApiResponse } from "next"; +import { NextHandler, createRouter } from "next-connect"; +import { handleOptionsSearch } from "./handlers/options"; +import { deleteResource, submitResource } from "./handlers/resources"; +import { NextAdminOptions, Permission } from "./types"; +import { hasPermission } from "./utils/permissions"; +import { + formatId, + getFormDataValues, + getJsonBody, + getResourceFromParams, + getResources, +} from "./utils/server"; + +type CreateAppHandlerParams

= { + /** + * Next-admin options + */ + options: NextAdminOptions; + /** + * Prisma client instance + */ + prisma: PrismaClient; + /** + * A function that acts as a middleware. Useful to add authentication logic for example. + */ + onRequest?: ( + req: NextApiRequest, + res: NextApiResponse, + next: NextHandler + ) => Promise; + // /** + // * A string indicating the name of the dynamic segment. + // * + // * Example: + // * - If the dynamic segment is `[[...nextadmin]]`, then the `paramKey` should be `nextadmin`. + // * - If the dynamic segment is `[[...admin]]`, then the `paramKey` should be `admin`. + // * + // * @default "nextadmin" + // */ + paramKey?: P; + /** + * Generated JSON schema from Prisma + */ + schema: any; +}; + +export const createApiRouter =

({ + options, + prisma, + paramKey = "nextadmin" as P, + onRequest, + schema, +}: CreateAppHandlerParams

) => { + const router = createRouter(); + const resources = getResources(options); + + if (onRequest) { + router.use(onRequest); + } + + router + .post(`${options.apiBasePath}/:model/actions/:id`, async (req, res) => { + const id = req.query[paramKey]!.at(-1)!; + + // Make sure we don't have a false positive with a model that could be named actions + const resource = getResourceFromParams( + [req.query[paramKey]![0]], + resources + ); + + if (!resource) { + return res.status(404).json({ error: "Resource not found" }); + } + + const modelAction = options.model?.[resource]?.actions?.find( + (action) => action.id === id + ); + + if (!modelAction) { + return res.status(404).json({ error: "Action not found" }); + } + + let body; + + try { + body = await getJsonBody(req); + } catch { + return res.status(400).json({ error: "Invalid JSON body" }); + } + + try { + await modelAction.action(body); + + return res.json({ ok: true }); + } catch (e) { + return res.status(500).json({ error: (e as Error).message }); + } + }) + .post(`${options.apiBasePath}/options`, async (req, res) => { + let body; + + try { + body = await getJsonBody(req); + } catch { + return res.status(400).json({ error: "Invalid JSON body" }); + } + + const data = await handleOptionsSearch(body, prisma, options); + + return res.json(data); + }) + .post(`${options.apiBasePath}/:model/:id?`, async (req, res) => { + const resource = getResourceFromParams( + [req.query[paramKey]![0]], + resources + ); + + if (!resource) { + return res.status(404).json({ error: "Resource not found" }); + } + + const body = await getFormDataValues(req); + const id = + req.query[paramKey]!.length === 2 + ? formatId(resource, req.query[paramKey]!.at(-1)!) + : undefined; + + try { + const response = await submitResource({ + prisma, + resource, + body, + id, + options, + schema, + }); + + if (response.error) { + return res.status(400).json({ + error: response.error, + validation: response.validation, + }); + } + + return res.status(id ? 200 : 201).json(response); + } catch (e) { + return res.status(500).json({ error: (e as Error).message }); + } + }) + .delete(`${options.apiBasePath}/:model/:id`, async (req, res) => { + const resource = getResourceFromParams( + [req.query[paramKey]![0]], + resources + ); + + if (!resource) { + return res.status(404).json({ error: "Resource not found" }); + } + + if (!hasPermission(options.model?.[resource], Permission.DELETE)) { + return res.status(403).json({ + error: "You don't have permission to delete this resource", + }); + } + + try { + await deleteResource({ + body: [req.query[paramKey]![1]], + prisma, + resource, + }); + + return res.json({ ok: true }); + } catch (e) { + return res.status(500).json({ error: (e as Error).message }); + } + }) + .delete(`${options.apiBasePath}/:model`, async (req, res) => { + const resource = getResourceFromParams( + [req.query[paramKey]![0]], + resources + ); + + if (!resource) { + return res.status(404).json({ error: "Resource not found" }); + } + + if (!hasPermission(options.model?.[resource], Permission.DELETE)) { + return res.status(403).json({ + error: "You don't have permission to delete this resource", + }); + } + + let body; + + try { + body = await getJsonBody(req); + } catch { + return res.status(400).json({ error: "Invalid JSON body" }); + } + + try { + await deleteResource({ body, prisma, resource }); + + return res.json({ ok: true }); + } catch (e) { + return res.status(500).json({ error: (e as Error).message }); + } + }); + + const executeRouteHandler = (req: NextApiRequest, res: NextApiResponse) => { + return router.run(req, res); + }; + + return { run: executeRouteHandler, router }; +}; diff --git a/packages/next-admin/src/components/Form.tsx b/packages/next-admin/src/components/Form.tsx index 30f80d94..15e067eb 100644 --- a/packages/next-admin/src/components/Form.tsx +++ b/packages/next-admin/src/components/Form.tsx @@ -43,7 +43,6 @@ import { ModelName, ModelOptions, Permission, - SubmitFormResult, } from "../types"; import { getSchemas } from "../utils/jsonSchema"; import { formatLabel, slugify } from "../utils/tools"; @@ -68,6 +67,7 @@ import { TooltipTrigger, } from "./radix/Tooltip"; import { useDeleteAction } from "../hooks/useDeleteAction"; +import { MessageProvider, useMessage } from "../context/MessageContext"; const RichTextField = dynamic(() => import("./inputs/RichText/RichTextField"), { ssr: false, @@ -139,6 +139,7 @@ const Form = ({ const [isPending, setIsPending] = useState(false); const allDisabled = edit && !canEdit; const { runDeletion } = useDeleteAction(resource); + const { showMessage, hideMessage } = useMessage(); useEffect(() => { if (!edit && !canCreate) { @@ -187,23 +188,34 @@ const Form = ({

diff --git a/packages/next-admin/src/context/MessageContext.tsx b/packages/next-admin/src/context/MessageContext.tsx new file mode 100644 index 00000000..8ded206b --- /dev/null +++ b/packages/next-admin/src/context/MessageContext.tsx @@ -0,0 +1,57 @@ +"use client"; +import { PropsWithChildren, createContext, useContext, useState } from "react"; +import { useRouterInternal } from "../hooks/useRouterInternal"; + +type MessageData = { + type: "error" | "info" | "success"; + message: string; +}; + +type MessageContextType = { + showMessage: (message: MessageData) => void; + message: MessageData | null; + hideMessage: () => void; +}; + +const MessageContext = createContext({ + showMessage: () => {}, + message: null, + hideMessage: () => {}, +}); + +export const MessageProvider = ({ children }: PropsWithChildren) => { + const { query } = useRouterInternal(); + const [message, setMessage] = useState(() => { + if (query.message) { + try { + const data = JSON.parse(query.message); + + if (data.type && data.message) { + return data; + } + + return null; + } catch { + return null; + } + } + + return null; + }); + + const showMessage = (messageData: MessageData) => { + setMessage(messageData); + }; + + const hideMessage = () => { + setMessage(null); + }; + + return ( + + {children} + + ); +}; + +export const useMessage = () => useContext(MessageContext); diff --git a/packages/next-admin/src/handler.ts b/packages/next-admin/src/handler.ts index 71261504..65993f4b 100644 --- a/packages/next-admin/src/handler.ts +++ b/packages/next-admin/src/handler.ts @@ -30,8 +30,7 @@ type CreateAppHandlerParams

= { */ onRequest?: ( req: NextRequest, - ctx: RequestContext

, - next: NextHandler + ctx: RequestContext

) => | ReturnType | ReturnType @@ -64,7 +63,7 @@ export const createAppHandler =

({ if (onRequest) { router.use(async (req, ctx, next) => { - const response = await onRequest(req, ctx, next); + const response = await onRequest(req, ctx); if (response) { return response; @@ -172,7 +171,7 @@ export const createAppHandler =

({ ); } - if (hasPermission(options.model?.[resource], Permission.DELETE)) { + if (!hasPermission(options.model?.[resource], Permission.DELETE)) { return NextResponse.json( { error: "You don't have permission to delete this resource" }, { status: 403 } @@ -197,7 +196,7 @@ export const createAppHandler =

({ ); } - if (hasPermission(options.model?.[resource], Permission.DELETE)) { + if (!hasPermission(options.model?.[resource], Permission.DELETE)) { return NextResponse.json( { error: "You don't have permission to delete this resource" }, { status: 403 } @@ -215,7 +214,7 @@ export const createAppHandler =

({ req: NextRequest, context: RequestContext

) => { - return router.run(req, context); + return router.run(req, context) as Promise; }; return { run: executeRouteHandler, router }; diff --git a/packages/next-admin/src/hooks/useAction.ts b/packages/next-admin/src/hooks/useAction.ts index 18ece5d7..1582d608 100644 --- a/packages/next-admin/src/hooks/useAction.ts +++ b/packages/next-admin/src/hooks/useAction.ts @@ -2,6 +2,7 @@ import { useRouterInternal } from "./useRouterInternal"; import { ModelAction, ModelName } from "../types"; import { useI18n } from "../context/I18nContext"; import { useConfig } from "../context/ConfigContext"; +import { useMessage } from "../context/MessageContext"; export const SPECIFIC_IDS_TO_RUN_ACTION = { DELETE: "__admin-delete", @@ -11,6 +12,7 @@ export const useAction = (resource: ModelName, ids: string[] | number[]) => { const { router } = useRouterInternal(); const { t } = useI18n(); const { apiBasePath } = useConfig(); + const { showMessage } = useMessage(); const runAction = async ( modelAction: ModelAction | Omit @@ -27,6 +29,9 @@ export const useAction = (resource: ModelName, ids: string[] | number[]) => { { method: "POST", body: JSON.stringify(ids), + headers: { + "Content-Type": "application/json", + }, } ); @@ -36,26 +41,17 @@ export const useAction = (resource: ModelName, ids: string[] | number[]) => { } if (modelAction.successMessage) { - router.setQuery( - { - message: JSON.stringify({ - type: "success", - content: t(modelAction.successMessage), - }), - error: null, - }, - true - ); + showMessage({ + type: "success", + message: t(modelAction.successMessage), + }); } } catch { if (modelAction.errorMessage) { - router.setQuery( - { - error: t(modelAction.errorMessage), - message: null, - }, - true - ); + showMessage({ + type: "error", + message: t(modelAction.errorMessage), + }); } } }; diff --git a/packages/next-admin/src/hooks/useDeleteAction.ts b/packages/next-admin/src/hooks/useDeleteAction.ts index bf3016bd..d9ffb799 100644 --- a/packages/next-admin/src/hooks/useDeleteAction.ts +++ b/packages/next-admin/src/hooks/useDeleteAction.ts @@ -1,6 +1,7 @@ import { useConfig } from "../context/ConfigContext"; import { useI18n } from "../context/I18nContext"; -import { ModelAction, ModelName } from "../types"; +import { useMessage } from "../context/MessageContext"; +import { ModelName } from "../types"; import { slugify } from "../utils/tools"; import { useRouterInternal } from "./useRouterInternal"; @@ -8,15 +9,20 @@ export const useDeleteAction = (resource: ModelName) => { const { apiBasePath } = useConfig(); const { router } = useRouterInternal(); const { t } = useI18n(); + const { showMessage } = useMessage(); const runDeletion = async (ids: string[] | number[]) => { const response = await fetch(`${apiBasePath}/${slugify(resource)}`, { method: "DELETE", body: JSON.stringify(ids), + headers: { + "Content-Type": "application/json", + }, }); if (!response.ok) { - throw new Error(); + const result = await response.json(); + throw new Error(result.error); } }; @@ -26,22 +32,16 @@ export const useDeleteAction = (resource: ModelName) => { ) { try { await runDeletion(ids); - router.setQuery( - { - message: JSON.stringify({ - type: "success", - content: t("list.row.actions.delete.success"), - }), - }, - true - ); + showMessage({ + type: "success", + message: t("list.row.actions.delete.success"), + }); + router.refresh(); } catch { - router.setQuery( - { - error: t("list.row.actions.delete.error"), - }, - true - ); + showMessage({ + type: "error", + message: t("list.row.actions.delete.error"), + }); } } }; diff --git a/packages/next-admin/src/router.tsx b/packages/next-admin/src/router.tsx index 3dbecfc9..9f55f1b4 100644 --- a/packages/next-admin/src/router.tsx +++ b/packages/next-admin/src/router.tsx @@ -1,28 +1,13 @@ import { PrismaClient } from "@prisma/client"; -import { - PrismaClientKnownRequestError, - PrismaClientValidationError, -} from "@prisma/client/runtime/library"; import { createRouter } from "next-connect"; -import { EditFieldsOptions, NextAdminOptions, Permission } from "./types"; -import { optionsFromResource } from "./utils/prisma"; +import { NextAdminOptions } from "./types"; import { getPropsFromParams } from "./utils/props"; import { formatSearchFields, - formattedFormData, - getBody, - getFormDataValues, - getModelIdProperty, getParamsFromUrl, - getPrismaModelForResource, - getResourceFromParams, - getResourceIdFromParam, getResources, - parseFormData, } from "./utils/server"; -import { slugify, uncapitalize } from "./utils/tools"; -import { validate } from "./utils/validator"; // Router export const nextAdminRouter = async ( @@ -65,315 +50,5 @@ export const nextAdminRouter = async ( return { props }; }) - .post(`${options.basePath}/api/options`, async (req, res) => { - const body = await getBody(req); - const { originModel, property, model, query, page, perPage } = - JSON.parse(body) as any; - - const data = await optionsFromResource({ - prisma, - originResource: originModel, - property: property, - resource: model, - options, - context: {}, - searchParams: new URLSearchParams({ - search: query, - page: page.toString(), - itemsPerPage: perPage.toString(), - }), - appDir: false, - }); - - res.setHeader("Content-Type", "application/json"); - res.write(JSON.stringify(data)); - res.end(); - }) - .post(async (req, res) => { - const params = getParamsFromUrl(req.url!, options.basePath); - const message = req.url?.split("?message=")[1]; - - const resource = getResourceFromParams(params, resources); - const requestOptions = formatSearchFields(req.url!); - - if (!resource) { - return { notFound: true }; - } - - const resourceId = getResourceIdFromParam(params[1], resource); - - const getProps = () => - getPropsFromParams({ - options, - prisma, - schema, - searchParams: requestOptions, - params, - isAppDir: false, - }); - - const { - __admin_action: action, - __admin_redirect: redirect, - id, - ...formData - } = await getFormDataValues(req); - - const dmmfSchema = getPrismaModelForResource(resource); - - const parsedFormData = parseFormData(formData, dmmfSchema?.fields!); - - const modelIdProperty = getModelIdProperty(resource); - - try { - // Delete redirect, display the list (this is needed because next keeps the HTTP method on redirects) - if ( - !resourceId && - params[1] !== "new" && - (action === "delete" || redirect) - ) { - if (message) { - return { - props: { - ...(await getProps()), - resource, - message: JSON.parse(decodeURIComponent(message)), - }, - }; - } - - return { - props: { - ...(await getProps()), - resource, - }, - }; - } - - // Delete - if (resourceId !== undefined && action === "delete") { - if ( - options?.model?.[resource]?.permissions && - !options?.model?.[resource]?.permissions?.includes( - Permission.DELETE - ) - ) { - res.statusCode = 403; - return { - props: { - ...(await getProps()), - error: "Unable to delete items of this model", - }, - }; - } - - // @ts-expect-error - await prisma[resource].delete({ - where: { - [modelIdProperty]: resourceId, - }, - }); - const message = { - type: "success", - content: "Deleted successfully", - }; - return { - redirect: { - destination: `${options.basePath}/${slugify( - resource - )}?message=${JSON.stringify(message)}`, - permanent: false, - }, - }; - } - - const fields = options.model?.[resource]?.edit - ?.fields as EditFieldsOptions; - - // Validate - validate(parsedFormData, fields); - - const { formattedData, complementaryFormattedData, errors } = - await formattedFormData( - formData, - dmmfSchema?.fields!, - schema, - resource, - resourceId, - fields - ); - - if (errors.length) { - return { - props: { - ...(await getProps()), - error: - options.model?.[resource]?.edit?.submissionErrorMessage ?? - "Submission error", - validation: errors.map((error) => ({ - property: error.field, - message: error.message, - })), - }, - }; - } - - if (resourceId !== undefined) { - if ( - options?.model?.[resource]?.permissions && - !options?.model?.[resource]?.permissions?.includes( - Permission.EDIT - ) - ) { - res.statusCode = 403; - return { - props: { - ...(await getProps()), - error: "Unable to update items of this model", - }, - }; - } - - // @ts-expect-error - await prisma[resource].update({ - where: { - [modelIdProperty]: resourceId, - }, - data: formattedData, - }); - - const message = { - type: "success", - content: "Updated successfully", - }; - - if (redirect) { - return { - redirect: { - destination: `${options.basePath}/${slugify( - resource - )}?message=${JSON.stringify(message)}`, - permanent: false, - }, - }; - } else { - return { - props: { - ...(await getProps()), - message, - }, - }; - } - } - - // Create - if ( - options?.model?.[resource]?.permissions && - options?.model?.[resource]?.permissions?.includes(Permission.CREATE) - ) { - res.statusCode = 403; - return { - props: { - ...(await getProps()), - error: "Unable to create items of this model", - }, - }; - } - - // @ts-expect-error - const createdData = await prisma[resource].create({ - data: formattedData, - }); - - // @ts-expect-error - await prisma[resource].update({ - where: { - [modelIdProperty]: createdData[modelIdProperty], - }, - data: complementaryFormattedData, - }); - - const pathname = redirect - ? `${options.basePath}/${slugify(resource)}` - : `${options.basePath}/${slugify(resource)}/${ - createdData[modelIdProperty] - }`; - return { - redirect: { - destination: `${pathname}?message=${JSON.stringify({ - type: "success", - content: "Created successfully", - })}`, - permanent: false, - }, - }; - } catch (error: any) { - if ( - error.constructor.name === PrismaClientValidationError.name || - error.constructor.name === PrismaClientKnownRequestError.name || - error.name === "ValidationError" - ) { - let data = parsedFormData; - - // TODO This could be improved by merging form values but it's breaking stuff - if (error.name === "ValidationError") { - error.errors.map((error: any) => { - // @ts-expect-error - data[error.property] = formData[error.property]; - }); - } - - return { - props: { - ...(await getProps()), - error: error.message, - validation: error.errors, - }, - }; - } - - throw error; - } - }) - .delete(async (req, res) => { - const params = getParamsFromUrl(req.url!, options.basePath); - const resource = getResourceFromParams(params, resources); - - if (!resource) { - return { notFound: true }; - } - - const body = await getBody(req); - const bodyJson = JSON.parse(body) as string[] | number[]; - - const modelIdProperty = getModelIdProperty(resource); - - // @ts-expect-error - await prisma[uncapitalize(resource)].deleteMany({ - where: { - [modelIdProperty]: { - in: bodyJson, - }, - }, - }); - - return { - props: { - ...(await getPropsFromParams({ - searchParams: formatSearchFields(req.url!), - options, - prisma, - schema, - params, - isAppDir: false, - })), - resource, - message: { - type: "success", - content: "Deleted successfully", - }, - }, - }; - }) ); }; diff --git a/packages/next-admin/src/utils/server.ts b/packages/next-admin/src/utils/server.ts index 8fb00cda..b58dc90f 100644 --- a/packages/next-admin/src/utils/server.ts +++ b/packages/next-admin/src/utils/server.ts @@ -2,6 +2,7 @@ import { Prisma } from "@prisma/client"; import formidable from "formidable"; import { IncomingMessage } from "http"; import { Writable } from "node:stream"; +import { NextApiRequest } from "next"; import { AdminFormData, EditFieldsOptions, @@ -81,13 +82,15 @@ export const getToStringForRelations = ( const editOptions = options?.model?.[modelName]?.edit; const relationOptions = options?.model?.[modelNameRelation]; const explicitManyToManyRelationField = - // @ts-expect-error + // @ts-expect-error editOptions?.fields?.[fieldName as Field]?.relationshipSearchField; const nonCheckedToString = // @ts-expect-error editOptions?.fields?.[fieldName]?.[ - explicitManyToManyRelationField ? "relationOptionFormatter" : "optionFormatter" + explicitManyToManyRelationField + ? "relationOptionFormatter" + : "optionFormatter" ] || relationOptions?.toString; const modelRelationIdField = getModelIdProperty(modelNameRelation); const toStringForRelations = @@ -295,7 +298,7 @@ export const transformData = ( explicitManyToManyRelationField ? (deepRelationModel?.type as ModelName) : modelRelation, - options, + options ); if (Array.isArray(data[key])) { acc[key] = data[key].map((item: any) => { @@ -1074,6 +1077,21 @@ export const getFormValuesFromFormData = async (formData: FormData) => { return formValues; }; +export const getJsonBody = async (req: NextApiRequest): Promise => { + let body = await getBody(req); + + // Handle case where bodyParser is disabled + if ( + body && + typeof body === "string" && + req.headers["content-type"] === "application/json" + ) { + body = JSON.parse(body); + } + + return body; +}; + export const getBody = async (req: IncomingMessage) => { return new Promise((resolve) => { const bodyParts: Buffer[] = []; diff --git a/packages/next-admin/tsconfig.json b/packages/next-admin/tsconfig.json index 96adeeb5..a30ce3ad 100644 --- a/packages/next-admin/tsconfig.json +++ b/packages/next-admin/tsconfig.json @@ -8,7 +8,8 @@ "./src/mainLayout.tsx", "./src/plugin.ts", "./src/preset.ts", - "./src/handler.ts" + "./src/handler.ts", + "./src/apiRoute.ts" ], "exclude": ["dist", "build", "node_modules"], "compilerOptions": { From d49107ea3a812dd6672d53f26ad6a5b713a0044c Mon Sep 17 00:00:00 2001 From: Hugo FOYART <11079152+foyarash@users.noreply.github.com> Date: Mon, 3 Jun 2024 10:31:48 +0200 Subject: [PATCH 03/89] wip docs --- apps/docs/pages/docs/api-docs.mdx | 120 ++++++++++++++++++++--- apps/docs/pages/docs/getting-started.mdx | 62 ++++++++---- packages/next-admin/README.md | 78 +++++++-------- 3 files changed, 190 insertions(+), 70 deletions(-) diff --git a/apps/docs/pages/docs/api-docs.mdx b/apps/docs/pages/docs/api-docs.mdx index b6d59d85..b8e7e7e0 100644 --- a/apps/docs/pages/docs/api-docs.mdx +++ b/apps/docs/pages/docs/api-docs.mdx @@ -17,10 +17,7 @@ import { Tabs } from "nextra/components"; - `options`: the [options](#next-admin-options) object - `schema`: the json schema generated by the `prisma generate` command - `prisma`: your Prisma client instance - - `action`: the [server action](https://nextjs.org/docs/app/api-reference/functions/server-actions) used to submit the form. It should be your own action, that wraps the `submitForm` action imported from `@premieroctet/next-admin/dist/actions`. - - `deleteAction`: the [server action](https://nextjs.org/docs/app/api-reference/functions/server-actions) used to delete one or more records in a resource. It is optional, and should be your own action. This action takes 3 parameters: `model` (the model name) and `ids` (an array of ids to delete). Next Admin provides a default action for deletion, that you can call in your own action. Check the example app for more details. - `getMessages`: a function with no parameters that returns translation messages. It is used to translate the default messages of the library. See [i18n](/docs/i18n) for more details. - - `searchPaginatedResourceAction`: the [server action](https://nextjs.org/docs/app/api-reference/functions/server-actions) used to search for resources in a selector widget. This is mandatory for App Router, and will be ignored on page router. Just like `action`, it should be your own action that wraps `searchPaginatedResource` imported from `@premieroctet/next-admin/dist/actions`. @@ -78,8 +75,26 @@ import { Tabs } from "nextra/components"; > The following example uses [next-auth](https://next-auth.js.org/) to handle authentication ```ts - // app/admin/[[...nextadmin]]/page.tsx + // app/api/admin/[[...nextadmin]]/route.ts + const { run } = createAppHandler({ + options, + prisma, + schema, + onRequest: (req) => { + const session = await getServerSession(authOptions); + const isAdmin = session?.user?.role === "SUPERADMIN"; + + if (!isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + } + }); + export { run as POST, run as GET, run as DELETE }; + ``` + + ```tsx + // app/admin/[[...nextadmin]]/page.tsx export default async function AdminPage({ params, searchParams, @@ -100,7 +115,6 @@ import { Tabs } from "nextra/components"; options, prisma, schema, - action: submitFormAction, }); return ; @@ -114,8 +128,33 @@ import { Tabs } from "nextra/components"; > The following example uses [next-auth](https://next-auth.js.org/) to handle authentication ```ts - // pages/api/admin/[[...nextadmin]].ts + export const config = { + api: { + bodyParser: false, + }, + }; + + const { run } = createApiRouter({ + options, + prisma, + schema: schema, + onRequest: (req, res, next) => { + const session = await getServerSession(req, res, authOptions); + const isAdmin = session?.user?.role === "SUPERADMIN"; + + if (!isAdmin) { + return res.status(403).json({ error: 'Forbidden' }) + } + return next() + } + }); + + export default run; + ``` + + ```ts + // pages/api/admin/[[...nextadmin]].ts export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { const session = await getServerSession(req, res, authOptions); const isAdmin = session?.user?.role === "SUPERADMIN"; // your role check @@ -335,12 +374,13 @@ The `exports` property is available in the `list` property. It's an object or an The `actions` property is an array of objects that allows you to define a set of actions that can be executed on one or more records of the resource. On the list view, there is a default action for deletion. The object can take the following properties: -| Name | Description | Default value | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | -| `title` | action title that will be shown in the action dropdown | undefined | -| `action` | an async function that will be triggered when selecting the action in the dropdown. For App Router, it must be defined as a server action. | undefined | -| `successMessage` | a message that will be displayed when the action is successful | undefined | -| `errorMessage` | a message that will be displayed when the action fails | undefined | +| Name | Description | Default value | +| ---------------- | ---------------------------------------------------------------------------------- | ------------- | +| `title` | action title that will be shown in the action dropdown | undefined | +| `id` | mandatory action unique identifier | | +| `action` | an async function that will be triggered when selecting the action in the dropdown | undefined | +| `successMessage` | a message that will be displayed when the action is successful | undefined | +| `errorMessage` | a message that will be displayed when the action fails | undefined | #### `sidebar` property @@ -481,3 +521,59 @@ The `user` property is an object that can take the following properties: | `data.name` | the name of the user displayed in the sidebar menu. This is required | | `data.picture` | the URL of the user's avatar displayed in the sidebar menu | | `logoutUrl` | an URL or path to logout the user. This is required. | + +## Api handlers + +Next-Admin export API handlers for both App Router and Page Router. + + + + + `createAppHandler` expects an object containing : + +| Name | Description | +| ----------- | -------------------------------------------------------------------------- | +| `options` | Your Next-Admin options. This is mandatory | +| `prisma` | Your Prisma client instance. This is mandatory | +| `schema` | Your JSON Schema generated by Prisma. This is mandatory. | +| `onRequest` | A function that is executed before any request. Useful for authentication. | + +It returns an object containing : + +| Name | Description | +| -------- | ---------------------------------------------------------------------------- | +| `run` | A function that executes the handler | +| `router` | The [next-connect](https://github.com/hoangvvo/next-connect) router instance | + + + + +`createApiHandler` expects an object containing : + +| Name | Description | +| ----------- | -------------------------------------------------------------------------- | +| `options` | Your Next-Admin options. This is mandatory | +| `prisma` | Your Prisma client instance. This is mandatory | +| `schema` | Your JSON Schema generated by Prisma. This is mandatory. | +| `onRequest` | A function that is executed before any request. Useful for authentication. | + +It returns an object containing : + +| Name | Description | +| -------- | ---------------------------------------------------------------------------- | +| `run` | A function that executes the handler | +| `router` | The [next-connect](https://github.com/hoangvvo/next-connect) router instance | + +When using `createApiHandler`, make sure that the api route file contains the following : + +```ts +export const config = { + api: { + bodyParser: false, + }, +}; +``` + + + + diff --git a/apps/docs/pages/docs/getting-started.mdx b/apps/docs/pages/docs/getting-started.mdx index 39e413f0..9ad34b59 100644 --- a/apps/docs/pages/docs/getting-started.mdx +++ b/apps/docs/pages/docs/getting-started.mdx @@ -135,6 +135,23 @@ This will create a `json-schema.json` file in the `prisma/json-schema` directory + + ```ts + // app/api/admin/[[...nextadmin]]/route.ts + import { createAppHandler } from "@premieroctet/next-admin/dist/handler"; + import schema from "@/prisma/json-schema/json-schema.json"; + import { prisma } from "@/prisma"; + import { options } from "@/options"; + + const { run } = createAppHandler({ + options, + prisma, + schema, + }); + + export { run as POST, run as GET, run as DELETE }; + ``` + ```tsx // app/admin/[[...nextadmin]]/page.tsx import { NextAdmin } from "@premieroctet/next-admin"; @@ -143,7 +160,6 @@ This will create a `json-schema.json` file in the `prisma/json-schema` directory import { options } from "../../../options"; import { prisma } from "../../../prisma"; import schema from "../../../prisma/json-schema/json-schema.json"; - import { submitFormAction } from "../../../actions/nextadmin"; import "../../../styles.css" // .css file containing tailiwnd rules export default async function AdminPage({ @@ -159,7 +175,6 @@ This will create a `json-schema.json` file in the `prisma/json-schema` directory options, prisma, schema, - action: submitFormAction, }); return ; @@ -180,26 +195,37 @@ This will create a `json-schema.json` file in the `prisma/json-schema` directory - You will also need to create the action: + + - ```tsx - // actions/nextadmin.ts - "use server"; - import { ActionParams } from "@premieroctet/next-admin"; - import { submitForm } from "@premieroctet/next-admin/dist/actions"; - import { prisma } from "../prisma"; - import { options } from "../options"; - - export const submitFormAction = async ( - params: ActionParams, - formData: FormData - ) => { - return submitForm({ ...params, options, prisma }, formData); + ```ts + // pages/api/admin/[[...nextadmin]].ts + import { options } from "@/pageRouterOptions"; + import { prisma } from "@/prisma"; + import { createApiRouter } from "@premieroctet/next-admin/dist/apiRoute"; + import schema from "@/prisma/json-schema/json-schema.json"; + + export const config = { + api: { + bodyParser: false, + }, }; + + const { run } = createApiRouter({ + options, + prisma, + schema: schema, + }); + + export default run; ``` - - + + + Make sure to export the config object to define no `bodyParser`. This is required to be able to parse FormData. + + + ```tsx // pages/admin/[[...nextadmin]].tsx import { GetServerSideProps, GetServerSidePropsResult } from "next"; diff --git a/packages/next-admin/README.md b/packages/next-admin/README.md index c5a4b885..91793613 100644 --- a/packages/next-admin/README.md +++ b/packages/next-admin/README.md @@ -43,6 +43,8 @@ To use the library in your Next.js application, follow these steps: 2. Add the file `[[...nextadmin]].js` to the `pages/admin` directory. 3. Export the `NextAdmin` component from the file. 4. Use `getServerSideProps` to pass the `props` from the `nextAdminRouter` to the `NextAdmin` component. +5. Add the file `[[...nextadmin]].js` to the `pages/api/admin` directory. +6. Export `run` retrieved from the `createApiRouter` function Bonus: Customize the admin dashboard by passing the `NextAdminOptions` options to the router and customize the admin dashboard by passing `dashboard` props to `NextAdmin` component. (More details in the [documentation](https://next-admin-docs.vercel.app/)) @@ -55,38 +57,19 @@ Here's a basic example of how to use the library: Set Next Admin server actions into your app: ```ts -// actions/nextadmin.ts -"use server"; -import { ActionParams, ModelName } from "@premieroctet/next-admin"; -import { - deleteResourceItems, - submitForm, - searchPaginatedResource, - SearchPaginatedResourceParams, -} from "@premieroctet/next-admin/dist/actions"; -import { prisma } from "../prisma"; -import { options } from "../options"; - -export const submitFormAction = async ( - params: ActionParams, - formData: FormData -) => { - return submitForm({ ...params, options, prisma }, formData); -}; - -export const deleteItem = async ( - model: ModelName, - ids: string[] | number[] -) => { - return deleteResourceItems(prisma, model, ids); -}; - -export const searchResource = async ( - actionParams: ActionParams, - params: SearchPaginatedResourceParams -) => { - return searchPaginatedResource({ ...actionParams, options, prisma }, params); -}; +// app/api/[[...nextadmin]]/route.ts +import { createAppHandler } from "@premieroctet/next-admin/dist/handler"; +import schema from "@/prisma/json-schema/json-schema.json"; +import { prisma } from "@/prisma"; +import { options } from "@/options"; + +const { run } = createAppHandler({ + options, + prisma, + schema, +}); + +export { run as POST, run as GET, run as DELETE }; ``` Then configure `page.tsx` @@ -100,11 +83,6 @@ import Dashboard from "../../../components/Dashboard"; import { options } from "../../../options"; import { prisma } from "../../../prisma"; import schema from "../../../prisma/json-schema/json-schema.json"; // generated by prisma-json-schema-generator on yarn run prisma generate -import { - submitFormAction, - deleteItem, - submitFormAction, -} from "../../../actions/nextadmin"; export default async function AdminPage({ params, @@ -119,9 +97,6 @@ export default async function AdminPage({ options, prisma, schema, - action: submitFormAction, - deleteAction: deleteItem, - searchPaginatedResourceAction: searchResource, }); return ; @@ -130,6 +105,29 @@ export default async function AdminPage({ #### Page Router +```tsx +// pages/api/admin/[[...nextadmin]].ts +import { options } from "@/pageRouterOptions"; +import { prisma } from "@/prisma"; +import { createApiRouter } from "@premieroctet/next-admin/dist/apiRoute"; +import schema from "@/prisma/json-schema/json-schema.json"; + +// --> IMPORTANT OR WE CANNOT HANDLE FORMDATA <-- +export const config = { + api: { + bodyParser: false, + }, +}; + +const { run } = createApiRouter({ + options, + prisma, + schema: schema, +}); + +export default run; +``` + ```tsx import { PrismaClient } from "@prisma/client"; import schema from "./../../../prisma/json-schema/json-schema.json"; // generated by prisma-json-schema-generator on yarn run prisma generate From 779b5ca19784c2d87d995066c121c8618e765570 Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Fri, 14 Jun 2024 16:31:39 +0200 Subject: [PATCH 04/89] Add get route --- .../[locale]/admin/[[...nextadmin]]/page.tsx | 7 ++--- packages/next-admin/src/handler.ts | 31 ++++++++++++++++--- packages/next-admin/src/types.ts | 6 +++- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx b/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx index 9313f40f..c94f3cdc 100644 --- a/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx +++ b/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx @@ -6,7 +6,7 @@ import { import { options } from "@/options"; import { prisma } from "@/prisma"; import schema from "@/prisma/json-schema/json-schema.json"; -import { NextAdmin } from "@premieroctet/next-admin"; +import { NextAdmin, PageProps } from "@premieroctet/next-admin"; import { getPropsFromParams } from "@premieroctet/next-admin/dist/appRouter"; import { Metadata, Viewport } from "next"; import { getMessages } from "next-intl/server"; @@ -23,10 +23,7 @@ export const metadata: Metadata = { export default async function AdminPage({ params, searchParams, -}: { - params: { [key: string]: string[] | string }; - searchParams: { [key: string]: string | string[] | undefined } | undefined; -}) { +}: PageProps) { const props = await getPropsFromParams({ params: params.nextadmin as string[], searchParams, diff --git a/packages/next-admin/src/handler.ts b/packages/next-admin/src/handler.ts index 65993f4b..cf43cd9d 100644 --- a/packages/next-admin/src/handler.ts +++ b/packages/next-admin/src/handler.ts @@ -1,16 +1,17 @@ -import { NextRequest, NextResponse } from "next/server"; -import { NextHandler, createEdgeRouter } from "next-connect"; -import { NextAdminOptions, Permission } from "./types"; import { PrismaClient } from "@prisma/client"; +import { createEdgeRouter } from "next-connect"; +import { NextRequest, NextResponse } from "next/server"; import { handleOptionsSearch } from "./handlers/options"; import { deleteResource, submitResource } from "./handlers/resources"; +import { NextAdminOptions, Permission } from "./types"; +import { hasPermission } from "./utils/permissions"; +import { fetchDataList } from "./utils/prisma"; import { formatId, getFormValuesFromFormData, getResourceFromParams, getResources, } from "./utils/server"; -import { hasPermission } from "./utils/permissions"; type RequestContext

= { params: Record; @@ -73,6 +74,28 @@ export const createAppHandler =

({ }); } + router.get(`${options.apiBasePath}/:model`, async (req, ctx) => { + const resource = getResourceFromParams(ctx.params[paramKey], resources); + + if (!resource) { + return NextResponse.json( + { error: "Resource not found" }, + { status: 404 } + ); + } + + const searchParams = new URLSearchParams(req.url.split("?")[1]); + + const data = await fetchDataList({ + prisma, + resource, + options, + searchParams, + }); + + return NextResponse.json(data); + }); + router .post(`${options.apiBasePath}/:model/actions/:id`, async (req, ctx) => { const id = ctx.params[paramKey].at(-1)!; diff --git a/packages/next-admin/src/types.ts b/packages/next-admin/src/types.ts index 23ebe376..ad8de529 100644 --- a/packages/next-admin/src/types.ts +++ b/packages/next-admin/src/types.ts @@ -2,7 +2,6 @@ import * as OutlineIcons from "@heroicons/react/24/outline"; import { Prisma, PrismaClient } from "@prisma/client"; import type { JSONSchema7 } from "json-schema"; import type { ChangeEvent, ReactNode } from "react"; -import type { SearchPaginatedResourceParams } from "./actions"; import type { PropertyValidationError } from "./exceptions/ValidationError"; declare type JSONSchema7Definition = JSONSchema7 & { @@ -721,3 +720,8 @@ export type Translations = { export const colorSchemes = ["light", "dark", "system"] as const; export type ColorScheme = (typeof colorSchemes)[number]; export type BasicColorScheme = Exclude; + +export type PageProps = { + readonly params: { [key: string]: string[] | string }; + readonly searchParams: { [key: string]: string | string[] | undefined } | undefined; +}; From c682adbfca12902052ea1fd55b236514a0dcc638 Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Mon, 17 Jun 2024 18:54:57 +0200 Subject: [PATCH 05/89] Format and remove actions --- .../[locale]/admin/[[...nextadmin]]/page.tsx | 17 ++------- .../next-admin/src/context/I18nContext.tsx | 6 +++- .../next-admin/src/tests/prismaUtils.test.ts | 4 +-- packages/next-admin/src/types.ts | 4 ++- packages/next-admin/src/utils/props.ts | 36 ++----------------- 5 files changed, 15 insertions(+), 52 deletions(-) diff --git a/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx b/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx index c94f3cdc..de90a6f1 100644 --- a/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx +++ b/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx @@ -1,8 +1,3 @@ -import { - deleteItem, - searchResource, - submitFormAction, -} from "@/actions/nextadmin"; import { options } from "@/options"; import { prisma } from "@/prisma"; import schema from "@/prisma/json-schema/json-schema.json"; @@ -20,24 +15,18 @@ export const metadata: Metadata = { icons: "/favicon.ico", }; -export default async function AdminPage({ - params, - searchParams, -}: PageProps) { +export default async function AdminPage({ params, searchParams }: PageProps) { const props = await getPropsFromParams({ params: params.nextadmin as string[], searchParams, options, prisma, schema, - action: submitFormAction, - deleteAction: deleteItem, - getMessages: () => - getMessages({ locale: params.locale as string }).then( + getMessages: (locale) => + getMessages({ locale }).then( (messages) => messages.admin as Record ), locale: params.locale as string, - searchPaginatedResourceAction: searchResource, }); return ( diff --git a/packages/next-admin/src/context/I18nContext.tsx b/packages/next-admin/src/context/I18nContext.tsx index ebe7860a..99495d39 100644 --- a/packages/next-admin/src/context/I18nContext.tsx +++ b/packages/next-admin/src/context/I18nContext.tsx @@ -7,7 +7,11 @@ type Props = { }; const I18nContext = createContext<{ - t: (key: string, options?: { [key: string]: any }, fallback?: string) => string; + t: ( + key: string, + options?: { [key: string]: any }, + fallback?: string + ) => string; }>({ t: () => "", }); diff --git a/packages/next-admin/src/tests/prismaUtils.test.ts b/packages/next-admin/src/tests/prismaUtils.test.ts index c0428ff6..6a00d904 100644 --- a/packages/next-admin/src/tests/prismaUtils.test.ts +++ b/packages/next-admin/src/tests/prismaUtils.test.ts @@ -60,7 +60,7 @@ describe("optionsFromResource", () => { author: 1, authorId: 1, rate: new Decimal(5), - order: 0 + order: 0, }, { id: 2, @@ -70,7 +70,7 @@ describe("optionsFromResource", () => { author: 1, authorId: 1, rate: new Decimal(5), - order: 1 + order: 1, }, ]; diff --git a/packages/next-admin/src/types.ts b/packages/next-admin/src/types.ts index ad8de529..dfff1930 100644 --- a/packages/next-admin/src/types.ts +++ b/packages/next-admin/src/types.ts @@ -723,5 +723,7 @@ export type BasicColorScheme = Exclude; export type PageProps = { readonly params: { [key: string]: string[] | string }; - readonly searchParams: { [key: string]: string | string[] | undefined } | undefined; + readonly searchParams: + | { [key: string]: string | string[] | undefined } + | undefined; }; diff --git a/packages/next-admin/src/utils/props.ts b/packages/next-admin/src/utils/props.ts index 5ca28278..aaafabf2 100644 --- a/packages/next-admin/src/utils/props.ts +++ b/packages/next-admin/src/utils/props.ts @@ -11,7 +11,6 @@ import { ModelIcon, ModelName, NextAdminOptions, - SubmitFormResult, } from "../types"; import { getCustomInputs } from "./options"; import { @@ -37,25 +36,9 @@ export type GetPropsFromParamsParams = { options: NextAdminOptions; schema: any; prisma: PrismaClient; - action?: ( - params: ActionParams, - formData: FormData - ) => Promise; isAppDir?: boolean; - deleteAction?: ( - resource: ModelName, - ids: string[] | number[] - ) => Promise; locale?: string; - getMessages?: () => Promise>; - searchPaginatedResourceAction?: ( - actionBaseParams: ActionParams, - params: SearchPaginatedResourceParams - ) => Promise<{ - data: any[]; - total: number; - error: string | null; - }>; + getMessages?: (locale: string) => Promise>; }; enum Page { @@ -69,12 +52,9 @@ export async function getPropsFromParams({ options, schema, prisma, - action, isAppDir = false, - deleteAction, locale, getMessages, - searchPaginatedResourceAction, }: GetPropsFromParamsParams): Promise< | AdminComponentProps | Omit @@ -108,18 +88,6 @@ export async function getPropsFromParams({ {} as Record ); - if (isAppDir && !action) { - throw new Error("action is required when using App router"); - } - - if (isAppDir && !deleteAction) { - throw new Error("deleteAction must be provided"); - } - - if (isAppDir && !searchPaginatedResourceAction) { - throw new Error("searchPaginatedResourceAction must be provided"); - } - const clientOptions: NextAdminOptions = extractSerializable(options); let defaultProps: AdminComponentProps = { resources, @@ -146,7 +114,7 @@ export async function getPropsFromParams({ }); if (getMessages) { - const messages = await getMessages(); + const messages = await getMessages(locale!); const dottedProperty = {} as any; const dot = (obj: object, prefix = "") => { Object.entries(obj).forEach(([key, value]) => { From d02b4fcdb4ab4eb00a40394fe28e82b5ce9775f4 Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Tue, 18 Jun 2024 13:23:59 +0200 Subject: [PATCH 06/89] Pursue --- packages/next-admin/src/appRouter.ts | 2 +- packages/next-admin/src/handler.ts | 23 ----------------------- packages/next-admin/src/types.ts | 11 +++++++++++ packages/next-admin/src/utils/props.ts | 14 +------------- 4 files changed, 13 insertions(+), 37 deletions(-) diff --git a/packages/next-admin/src/appRouter.ts b/packages/next-admin/src/appRouter.ts index ad246d7f..c41d669e 100644 --- a/packages/next-admin/src/appRouter.ts +++ b/packages/next-admin/src/appRouter.ts @@ -1,6 +1,6 @@ "use server"; +import { GetPropsFromParamsParams } from "./types"; import { - GetPropsFromParamsParams, getPropsFromParams as _getPropsFromParams, } from "./utils/props"; diff --git a/packages/next-admin/src/handler.ts b/packages/next-admin/src/handler.ts index cf43cd9d..6fe44e32 100644 --- a/packages/next-admin/src/handler.ts +++ b/packages/next-admin/src/handler.ts @@ -5,7 +5,6 @@ import { handleOptionsSearch } from "./handlers/options"; import { deleteResource, submitResource } from "./handlers/resources"; import { NextAdminOptions, Permission } from "./types"; import { hasPermission } from "./utils/permissions"; -import { fetchDataList } from "./utils/prisma"; import { formatId, getFormValuesFromFormData, @@ -74,28 +73,6 @@ export const createAppHandler =

({ }); } - router.get(`${options.apiBasePath}/:model`, async (req, ctx) => { - const resource = getResourceFromParams(ctx.params[paramKey], resources); - - if (!resource) { - return NextResponse.json( - { error: "Resource not found" }, - { status: 404 } - ); - } - - const searchParams = new URLSearchParams(req.url.split("?")[1]); - - const data = await fetchDataList({ - prisma, - resource, - options, - searchParams, - }); - - return NextResponse.json(data); - }); - router .post(`${options.apiBasePath}/:model/actions/:id`, async (req, ctx) => { const id = ctx.params[paramKey].at(-1)!; diff --git a/packages/next-admin/src/types.ts b/packages/next-admin/src/types.ts index dfff1930..6f6a18b1 100644 --- a/packages/next-admin/src/types.ts +++ b/packages/next-admin/src/types.ts @@ -727,3 +727,14 @@ export type PageProps = { | { [key: string]: string | string[] | undefined } | undefined; }; + +export type GetPropsFromParamsParams = { + params?: string[]; + searchParams: { [key: string]: string | string[] | undefined } | undefined; + options: NextAdminOptions; + schema: any; + prisma: PrismaClient; + isAppDir?: boolean; + locale?: string; + getMessages?: (locale: string) => Promise>; +}; \ No newline at end of file diff --git a/packages/next-admin/src/utils/props.ts b/packages/next-admin/src/utils/props.ts index aaafabf2..417bfcad 100644 --- a/packages/next-admin/src/utils/props.ts +++ b/packages/next-admin/src/utils/props.ts @@ -1,11 +1,10 @@ import { Prisma, PrismaClient } from "@prisma/client"; import { cloneDeep } from "lodash"; -import type { SearchPaginatedResourceParams } from "../actions"; import { - ActionParams, AdminComponentProps, EditOptions, Field, + GetPropsFromParamsParams, ListOptions, MainLayoutProps, ModelIcon, @@ -30,17 +29,6 @@ import { } from "./server"; import { extractSerializable } from "./tools"; -export type GetPropsFromParamsParams = { - params?: string[]; - searchParams: { [key: string]: string | string[] | undefined } | undefined; - options: NextAdminOptions; - schema: any; - prisma: PrismaClient; - isAppDir?: boolean; - locale?: string; - getMessages?: (locale: string) => Promise>; -}; - enum Page { LIST = 1, EDIT = 2, From 19062db8cfade37a139c8cbf440bde31cc9a5932 Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Wed, 19 Jun 2024 17:17:08 +0200 Subject: [PATCH 07/89] Forward locale value --- apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx | 1 - packages/next-admin/src/utils/props.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx b/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx index de90a6f1..c9d28415 100644 --- a/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx +++ b/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx @@ -32,7 +32,6 @@ export default async function AdminPage({ params, searchParams }: PageProps) { return ( Date: Thu, 20 Jun 2024 14:16:45 +0200 Subject: [PATCH 08/89] Pass test --- apps/example/package.json | 2 +- packages/next-admin/package.json | 10 +++++----- packages/next-admin/src/tests/singleton.tsx | 1 + turbo.json | 3 +-- yarn.lock | 8 ++++---- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/example/package.json b/apps/example/package.json index 6b79bc61..2f14fa0c 100644 --- a/apps/example/package.json +++ b/apps/example/package.json @@ -21,7 +21,7 @@ "@heroicons/react": "^2.0.18", "@picocss/pico": "^1.5.7", "@premieroctet/next-admin": "*", - "@prisma/client": "^5.13.0", + "@prisma/client": "5.14.0", "@tremor/react": "^3.2.2", "babel-plugin-superjson-next": "^0.4.5", "next": "^14.0.3", diff --git a/packages/next-admin/package.json b/packages/next-admin/package.json index f11e987f..04b1f682 100644 --- a/packages/next-admin/package.json +++ b/packages/next-admin/package.json @@ -41,7 +41,11 @@ "peerDependencies": { "@prisma/client": ">=4", "prisma": ">=4", - "prisma-json-schema-generator": ">=3 <=5.1.1" + "prisma-json-schema-generator": ">=3 <=5.1.1", + "react": ">=17", + "react-dom": ">=17", + "next": ">=12", + "typescript": ">=4" }, "dependencies": { "@dnd-kit/core": "^6.1.0", @@ -66,13 +70,9 @@ "clsx": "^1.2.1", "formidable": "^3.5.1", "lodash.range": "^3.2.0", - "next": "13.2.4", "next-connect": "^1.0.0-next.3", "nextjs-toploader": "^1.6.6", - "react": "18.2.0", - "react-dom": "18.2.0", "tailwind-merge": "^2.3.0", - "typescript": "^5.1.6", "util": "^0.12.5" }, "devDependencies": { diff --git a/packages/next-admin/src/tests/singleton.tsx b/packages/next-admin/src/tests/singleton.tsx index 80a1e4b5..14f82997 100644 --- a/packages/next-admin/src/tests/singleton.tsx +++ b/packages/next-admin/src/tests/singleton.tsx @@ -671,6 +671,7 @@ export const schema: Schema = { export const options: NextAdminOptions = { basePath: "/admin", + apiBasePath: "/api/admin", model: { User: { toString: (user) => `${user.name} (${user.email})`, diff --git a/turbo.json b/turbo.json index 79817605..ee4481e6 100644 --- a/turbo.json +++ b/turbo.json @@ -1,7 +1,6 @@ { "$schema": "https://turbo.build/schema.json", "globalDependencies": ["**/.env.*local"], - "globalDotEnv": ["**/.env"], "globalEnv": [ "NODE_ENV", "BASE_URL", @@ -9,7 +8,7 @@ "POSTGRES_PRISMA_URL", "POSTGRES_URL_NON_POOLING" ], - "pipeline": { + "tasks": { "start": {}, "build": { "dependsOn": ["^build"], diff --git a/yarn.lock b/yarn.lock index 85666b32..0b1e531c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1553,7 +1553,7 @@ resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz" integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== -"@prisma/client@^5.13.0": +"@prisma/client@5.14.0": version "5.14.0" resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.14.0.tgz#dadca5bb1137ddcebb454bbdaf89423823d3363f" integrity sha512-akMSuyvLKeoU4LeyBAUdThP/uhVP3GuLygFE3MlYzaCb3/J8SfsYBE5PkaFuLuVpLyA6sFoW+16z/aPhNAESqg== @@ -7549,7 +7549,7 @@ next-themes@^0.2.1: resolved "https://registry.npmjs.org/next-themes/-/next-themes-0.2.1.tgz" integrity sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A== -next@13.2.4, next@^13.1.1: +next@^13.1.1: version "13.2.4" resolved "https://registry.npmjs.org/next/-/next-13.2.4.tgz" integrity sha512-g1I30317cThkEpvzfXujf0O4wtaQHtDCLhlivwlTJ885Ld+eOgcz7r3TGQzeU+cSRoNHtD8tsJgzxVdYojFssw== @@ -8296,7 +8296,7 @@ react-day-picker@^8.7.1: resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-8.8.0.tgz#582b9d5e54a84926f159be2b4004801707b3c885" integrity sha512-QIC3uOuyGGbtypbd5QEggsCSqVaPNu8kzUWquZ7JjW9fuWB9yv7WyixKmnaFelTLXFdq7h7zU6n/aBleBqe/dA== -react-dom@18.2.0, react-dom@^18.2.0: +react-dom@^18.2.0: version "18.2.0" resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz" integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== @@ -8405,7 +8405,7 @@ react-transition-group@^4.4.5: loose-envify "^1.4.0" prop-types "^15.6.2" -react@18.2.0, react@^18.2.0: +react@^18.2.0: version "18.2.0" resolved "https://registry.npmjs.org/react/-/react-18.2.0.tgz" integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== From 3617012658f726f0b4e7b92fe637b262d768ad95 Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Thu, 20 Jun 2024 14:45:13 +0200 Subject: [PATCH 09/89] Update turbo --- turbo.json | 15 ++++++++++--- yarn.lock | 66 +++++++++++++++++++++++++++--------------------------- 2 files changed, 45 insertions(+), 36 deletions(-) diff --git a/turbo.json b/turbo.json index ee4481e6..d3d3d5d3 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,9 @@ { "$schema": "https://turbo.build/schema.json", - "globalDependencies": ["**/.env.*local"], + "globalDependencies": [ + "**/.env.*local", + "**/.env" + ], "globalEnv": [ "NODE_ENV", "BASE_URL", @@ -11,8 +14,14 @@ "tasks": { "start": {}, "build": { - "dependsOn": ["^build"], - "outputs": ["dist/**", ".next/**", "!.next/cache/**"] + "dependsOn": [ + "^build" + ], + "outputs": [ + "dist/**", + ".next/**", + "!.next/cache/**" + ] }, "lint": { "outputs": [] diff --git a/yarn.lock b/yarn.lock index 0b1e531c..4ced2732 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9595,47 +9595,47 @@ tty-table@^4.1.5: wcwidth "^1.0.1" yargs "^17.7.1" -turbo-darwin-64@1.10.3: - version "1.10.3" - resolved "https://registry.yarnpkg.com/turbo-darwin-64/-/turbo-darwin-64-1.10.3.tgz#b04f715530ae3c8b6d1ce86229236a7513a28c8c" - integrity sha512-IIB9IomJGyD3EdpSscm7Ip1xVWtYb7D0x7oH3vad3gjFcjHJzDz9xZ/iw/qItFEW+wGFcLSRPd+1BNnuLM8AsA== +turbo-darwin-64@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/turbo-darwin-64/-/turbo-darwin-64-2.0.4.tgz#83c7835f8ba1f7a5473487ce73cfc8d5ad523614" + integrity sha512-x9mvmh4wudBstML8Z8IOmokLWglIhSfhQwnh2gBCSqabgVBKYvzl8Y+i+UCNPxheCGTgtsPepTcIaKBIyFIcvw== -turbo-darwin-arm64@1.10.3: - version "1.10.3" - resolved "https://registry.npmjs.org/turbo-darwin-arm64/-/turbo-darwin-arm64-1.10.3.tgz" - integrity sha512-SBNmOZU9YEB0eyNIxeeQ+Wi0Ufd+nprEVp41rgUSRXEIpXjsDjyBnKnF+sQQj3+FLb4yyi/yZQckB+55qXWEsw== +turbo-darwin-arm64@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/turbo-darwin-arm64/-/turbo-darwin-arm64-2.0.4.tgz#046e5768e9d6b490b7108d5bef3f4a1594aca0ba" + integrity sha512-/B1Ih8zPRGVw5vw4SlclOf3C/woJ/2T6ieH6u54KT4wypoaVyaiyMqBcziIXycdObIYr7jQ+raHO7q3mhay9/A== -turbo-linux-64@1.10.3: - version "1.10.3" - resolved "https://registry.yarnpkg.com/turbo-linux-64/-/turbo-linux-64-1.10.3.tgz#d6cbd198e95620e75baa70f1e09f355db6d3e1de" - integrity sha512-kvAisGKE7xHJdyMxZLvg53zvHxjqPK1UVj4757PQqtx9dnjYHSc8epmivE6niPgDHon5YqImzArCjVZJYpIGHQ== +turbo-linux-64@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/turbo-linux-64/-/turbo-linux-64-2.0.4.tgz#eab8c183a11b26ddec251d62778313a495971e4f" + integrity sha512-6aG670e5zOWu6RczEYcB81nEl8EhiGJEvWhUrnAfNEUIMBEH1pR5SsMmG2ol5/m3PgiRM12r13dSqTxCLcHrVg== -turbo-linux-arm64@1.10.3: - version "1.10.3" - resolved "https://registry.yarnpkg.com/turbo-linux-arm64/-/turbo-linux-arm64-1.10.3.tgz#53148b79e84d020ece82c8af170a2f1d16a01b5b" - integrity sha512-Qgaqln0IYRgyL0SowJOi+PNxejv1I2xhzXOI+D+z4YHbgSx87ox1IsALYBlK8VRVYY8VCXl+PN12r1ioV09j7A== +turbo-linux-arm64@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/turbo-linux-arm64/-/turbo-linux-arm64-2.0.4.tgz#2dcc3f1d3e56209736b2ce3d849b80e0d7116e42" + integrity sha512-AXfVOjst+mCtPDFT4tCu08Qrfv12Nj7NDd33AjGwV79NYN1Y1rcFY59UQ4nO3ij3rbcvV71Xc+TZJ4csEvRCSg== -turbo-windows-64@1.10.3: - version "1.10.3" - resolved "https://registry.yarnpkg.com/turbo-windows-64/-/turbo-windows-64-1.10.3.tgz#a90af7313cbada57296d672515c4957ef86e5905" - integrity sha512-rbH9wManURNN8mBnN/ZdkpUuTvyVVEMiUwFUX4GVE5qmV15iHtZfDLUSGGCP2UFBazHcpNHG1OJzgc55GFFrUw== +turbo-windows-64@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/turbo-windows-64/-/turbo-windows-64-2.0.4.tgz#b2440d82892c983088ed386f9126d365594fc1a5" + integrity sha512-QOnUR9hKl0T5gq5h1fAhVEqBSjpcBi/BbaO71YGQNgsr6pAnCQdbG8/r3MYXet53efM0KTdOhieWeO3KLNKybA== -turbo-windows-arm64@1.10.3: - version "1.10.3" - resolved "https://registry.yarnpkg.com/turbo-windows-arm64/-/turbo-windows-arm64-1.10.3.tgz#3ed80e34aa5a432b312ccf2f4770c63a72d0b254" - integrity sha512-ThlkqxhcGZX39CaTjsHqJnqVe+WImjX13pmjnpChz6q5HHbeRxaJSFzgrHIOt0sUUVx90W/WrNRyoIt/aafniw== +turbo-windows-arm64@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/turbo-windows-arm64/-/turbo-windows-arm64-2.0.4.tgz#e943709535baf233f5b85ed35cd95dcf86815283" + integrity sha512-3v8WpdZy1AxZw0gha0q3caZmm+0gveBQ40OspD6mxDBIS+oBtO5CkxhIXkFJJW+jDKmDlM7wXDIGfMEq+QyNCQ== turbo@latest: - version "1.10.3" - resolved "https://registry.yarnpkg.com/turbo/-/turbo-1.10.3.tgz#125944cb59f3aa60ca4aa93e4c505b974fe55097" - integrity sha512-U4gKCWcKgLcCjQd4Pl8KJdfEKumpyWbzRu75A6FCj6Ctea1PIm58W6Ltw1QXKqHrl2pF9e1raAskf/h6dlrPCA== + version "2.0.4" + resolved "https://registry.yarnpkg.com/turbo/-/turbo-2.0.4.tgz#4fb6f0bf3be905953825de0368203e849c91e412" + integrity sha512-Ilme/2Q5kYw0AeRr+aw3s02+WrEYaY7U8vPnqSZU/jaDG/qd6jHVN6nRWyd/9KXvJGYM69vE6JImoGoyNjLwaw== optionalDependencies: - turbo-darwin-64 "1.10.3" - turbo-darwin-arm64 "1.10.3" - turbo-linux-64 "1.10.3" - turbo-linux-arm64 "1.10.3" - turbo-windows-64 "1.10.3" - turbo-windows-arm64 "1.10.3" + turbo-darwin-64 "2.0.4" + turbo-darwin-arm64 "2.0.4" + turbo-linux-64 "2.0.4" + turbo-linux-arm64 "2.0.4" + turbo-windows-64 "2.0.4" + turbo-windows-arm64 "2.0.4" type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" From a85f001477297ef0fa9ccf18574b82608a72c4d6 Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Thu, 20 Jun 2024 18:32:02 +0200 Subject: [PATCH 10/89] Refacto code --- .../[locale]/admin/[[...nextadmin]]/page.tsx | 4 +- .../app/api/admin/[[...nextadmin]]/route.ts | 10 ++-- .../api/pagerouter/admin/[[...nextadmin]].ts | 4 +- .../pagerouter/admin/[[...nextadmin]].tsx | 15 ++---- apps/example/tailwind.config.js | 2 +- packages/next-admin/package.json | 12 ++++- .../src/{handler.ts => appHandler.ts} | 2 +- packages/next-admin/src/appRouter.ts | 11 ++-- .../src/{apiRoute.ts => pageHandler.ts} | 2 +- packages/next-admin/src/pageRouter.tsx | 28 ++++++++++ packages/next-admin/src/router.tsx | 54 ------------------- packages/next-admin/src/utils/props.ts | 2 +- yarn.lock | 14 +++-- 13 files changed, 71 insertions(+), 89 deletions(-) rename packages/next-admin/src/{handler.ts => appHandler.ts} (98%) rename packages/next-admin/src/{apiRoute.ts => pageHandler.ts} (98%) create mode 100644 packages/next-admin/src/pageRouter.tsx delete mode 100644 packages/next-admin/src/router.tsx diff --git a/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx b/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx index c9d28415..aeb44fcb 100644 --- a/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx +++ b/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx @@ -2,7 +2,7 @@ import { options } from "@/options"; import { prisma } from "@/prisma"; import schema from "@/prisma/json-schema/json-schema.json"; import { NextAdmin, PageProps } from "@premieroctet/next-admin"; -import { getPropsFromParams } from "@premieroctet/next-admin/dist/appRouter"; +import { getNextAdminProps } from "@premieroctet/next-admin/dist/appRouter"; import { Metadata, Viewport } from "next"; import { getMessages } from "next-intl/server"; @@ -16,7 +16,7 @@ export const metadata: Metadata = { }; export default async function AdminPage({ params, searchParams }: PageProps) { - const props = await getPropsFromParams({ + const props = await getNextAdminProps({ params: params.nextadmin as string[], searchParams, options, diff --git a/apps/example/app/api/admin/[[...nextadmin]]/route.ts b/apps/example/app/api/admin/[[...nextadmin]]/route.ts index 20bd2a8e..13720cae 100644 --- a/apps/example/app/api/admin/[[...nextadmin]]/route.ts +++ b/apps/example/app/api/admin/[[...nextadmin]]/route.ts @@ -1,12 +1,12 @@ -import { createAppHandler } from "@premieroctet/next-admin/dist/handler"; -import schema from "@/prisma/json-schema/json-schema.json"; -import { prisma } from "@/prisma"; import { options } from "@/options"; +import { prisma } from "@/prisma"; +import schema from "@/prisma/json-schema/json-schema.json"; +import { createHandler } from "@premieroctet/next-admin/dist/appHandler"; -const { run } = createAppHandler({ +const { run } = createHandler({ options, prisma, schema, }); -export { run as POST, run as GET, run as DELETE }; +export { run as DELETE, run as GET, run as POST }; diff --git a/apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts b/apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts index 5504a872..be8318d4 100644 --- a/apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts +++ b/apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts @@ -1,7 +1,7 @@ import { options } from "@/pageRouterOptions"; import { prisma } from "@/prisma"; -import { createApiRouter } from "@premieroctet/next-admin/dist/apiRoute"; import schema from "@/prisma/json-schema/json-schema.json"; +import { createHandler } from "@premieroctet/next-admin/dist/pageHandler"; export const config = { api: { @@ -9,7 +9,7 @@ export const config = { }, }; -const { run } = createApiRouter({ +const { run } = createHandler({ options, prisma, schema: schema, diff --git a/apps/example/pages/pagerouter/admin/[[...nextadmin]].tsx b/apps/example/pages/pagerouter/admin/[[...nextadmin]].tsx index 436031ed..4c1fa28e 100644 --- a/apps/example/pages/pagerouter/admin/[[...nextadmin]].tsx +++ b/apps/example/pages/pagerouter/admin/[[...nextadmin]].tsx @@ -1,5 +1,6 @@ import { AdminComponentProps, NextAdmin } from "@premieroctet/next-admin"; -import { GetServerSideProps, GetServerSidePropsResult } from "next"; +import { getNextAdminProps } from "@premieroctet/next-admin/dist/pageRouter"; +import { GetServerSideProps } from "next"; import { options } from "../../../pageRouterOptions"; import { prisma } from "../../../prisma"; import schema from "../../../prisma/json-schema/json-schema.json"; @@ -22,13 +23,5 @@ export default function Admin(props: AdminComponentProps) { ); } -export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { - const { nextAdminRouter } = await import( - "@premieroctet/next-admin/dist/router" - ); - - const adminRouter = await nextAdminRouter(prisma, schema, pageOptions); - return adminRouter.run(req, res) as Promise< - GetServerSidePropsResult<{ [key: string]: any }> - >; -}; +export const getServerSideProps: GetServerSideProps = async ({ req }) => + await getNextAdminProps(prisma, schema, pageOptions, req); diff --git a/apps/example/tailwind.config.js b/apps/example/tailwind.config.js index 66d1ac9e..10507115 100644 --- a/apps/example/tailwind.config.js +++ b/apps/example/tailwind.config.js @@ -146,5 +146,5 @@ module.exports = { }, ], plugins: [require("@headlessui/tailwindcss")], - presets: [require("@premieroctet/next-admin/dist/preset")], + presets: [require("@premieroctet/next-admin/tailwind-preset")], }; diff --git a/packages/next-admin/package.json b/packages/next-admin/package.json index 04b1f682..24aad139 100644 --- a/packages/next-admin/package.json +++ b/packages/next-admin/package.json @@ -1,7 +1,17 @@ { "name": "@premieroctet/next-admin", "version": "4.4.4", - "main": "./dist/index.js", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.js" + }, + "./tailwind-preset": { + "import": "./dist/preset.js", + "require": "./dist/preset.js" + } + }, "description": "Next-Admin provides a customizable and turnkey admin dashboard for applications built with Next.js and powered by the Prisma ORM. It aims to simplify the development process by providing a turnkey admin system that can be easily integrated into your project.", "keywords": [ "next.js", diff --git a/packages/next-admin/src/handler.ts b/packages/next-admin/src/appHandler.ts similarity index 98% rename from packages/next-admin/src/handler.ts rename to packages/next-admin/src/appHandler.ts index 6fe44e32..69506a56 100644 --- a/packages/next-admin/src/handler.ts +++ b/packages/next-admin/src/appHandler.ts @@ -51,7 +51,7 @@ type CreateAppHandlerParams

= { schema: any; }; -export const createAppHandler =

({ +export const createHandler =

({ options, prisma, paramKey = "nextadmin" as P, diff --git a/packages/next-admin/src/appRouter.ts b/packages/next-admin/src/appRouter.ts index c41d669e..e9fd6d9c 100644 --- a/packages/next-admin/src/appRouter.ts +++ b/packages/next-admin/src/appRouter.ts @@ -1,9 +1,8 @@ "use server"; import { GetPropsFromParamsParams } from "./types"; -import { - getPropsFromParams as _getPropsFromParams, -} from "./utils/props"; +import { getPropsFromParams as _getPropsFromParams } from "./utils/props"; -export const getPropsFromParams = ( - params: Omit -) => _getPropsFromParams({ ...params, isAppDir: true }); +export const getNextAdminProps = ({ + isAppDir = true, + ...params +}: GetPropsFromParamsParams) => _getPropsFromParams({ ...params, isAppDir }); diff --git a/packages/next-admin/src/apiRoute.ts b/packages/next-admin/src/pageHandler.ts similarity index 98% rename from packages/next-admin/src/apiRoute.ts rename to packages/next-admin/src/pageHandler.ts index 01534525..c2d2f118 100644 --- a/packages/next-admin/src/apiRoute.ts +++ b/packages/next-admin/src/pageHandler.ts @@ -46,7 +46,7 @@ type CreateAppHandlerParams

= { schema: any; }; -export const createApiRouter =

({ +export const createHandler =

({ options, prisma, paramKey = "nextadmin" as P, diff --git a/packages/next-admin/src/pageRouter.tsx b/packages/next-admin/src/pageRouter.tsx new file mode 100644 index 00000000..256cacee --- /dev/null +++ b/packages/next-admin/src/pageRouter.tsx @@ -0,0 +1,28 @@ +import { PrismaClient } from "@prisma/client"; + +import { IncomingMessage } from "node:http"; +import { NextAdminOptions } from "./types"; +import { getPropsFromParams } from "./utils/props"; +import { formatSearchFields, getParamsFromUrl } from "./utils/server"; + +// Router +export const getNextAdminProps = async ( + prisma: PrismaClient, + schema: any, + options: NextAdminOptions, + req: IncomingMessage +) => { + const params = getParamsFromUrl(req.url!, options.basePath); + const requestOptions = formatSearchFields(req.url!); + + const props = await getPropsFromParams({ + options, + prisma, + schema, + searchParams: requestOptions, + params, + isAppDir: false, + }); + + return { props }; +}; diff --git a/packages/next-admin/src/router.tsx b/packages/next-admin/src/router.tsx deleted file mode 100644 index 9f55f1b4..00000000 --- a/packages/next-admin/src/router.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { PrismaClient } from "@prisma/client"; -import { createRouter } from "next-connect"; - -import { NextAdminOptions } from "./types"; -import { getPropsFromParams } from "./utils/props"; -import { - formatSearchFields, - getParamsFromUrl, - getResources, -} from "./utils/server"; - -// Router -export const nextAdminRouter = async ( - prisma: PrismaClient, - schema: any, - options: NextAdminOptions -) => { - const resources = getResources(options); - const defaultProps = { resources, basePath: options.basePath }; - - return ( - createRouter() - // Error handling middleware - .use(async (req, res, next) => { - try { - return await next(); - } catch (e: any) { - if (process.env.NODE_ENV === "development") { - throw e; - } - - return { - props: { ...defaultProps, error: e.message }, - }; - } - }) - .get(async (req, res) => { - const params = getParamsFromUrl(req.url!, options.basePath); - - const requestOptions = formatSearchFields(req.url!); - - const props = await getPropsFromParams({ - options, - prisma, - schema, - searchParams: requestOptions, - params, - isAppDir: false, - }); - - return { props }; - }) - ); -}; diff --git a/packages/next-admin/src/utils/props.ts b/packages/next-admin/src/utils/props.ts index fc473399..e0ab7d1c 100644 --- a/packages/next-admin/src/utils/props.ts +++ b/packages/next-admin/src/utils/props.ts @@ -40,7 +40,7 @@ export async function getPropsFromParams({ options, schema, prisma, - isAppDir = false, + isAppDir = true, locale, getMessages, }: GetPropsFromParamsParams): Promise< diff --git a/yarn.lock b/yarn.lock index 4ced2732..cb57e652 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2229,6 +2229,11 @@ resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz" integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== +"@tsconfig/node16@^1.0.3": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" + integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== + "@types/acorn@^4.0.0": version "4.0.6" resolved "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz" @@ -7506,11 +7511,12 @@ negotiator@^0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== -next-connect@^1.0.0-next.3: - version "1.0.0-next.3" - resolved "https://registry.npmjs.org/next-connect/-/next-connect-1.0.0-next.3.tgz" - integrity sha512-i1kb8rz/3lm6z68Lnh18juHGgbgFVZXXAIiElaASGXxDZ8mJ2EVxdbTXX8NiF9BbGDBeao7u6uKMqw5ZLZg/Kg== +next-connect@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/next-connect/-/next-connect-1.0.0.tgz#49361a92b2d22db1cce73f94dfe793cd4b9e0106" + integrity sha512-FeLURm9MdvzY1SDUGE74tk66mukSqL6MAzxajW7Gqh6DZKBZLrXmXnGWtHJZXkfvoi+V/DUe9Hhtfkl4+nTlYA== dependencies: + "@tsconfig/node16" "^1.0.3" regexparam "^2.0.1" next-intl@^3.3.2: From c59a062a71ae07d9ef7b63f104fec60172da722c Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Fri, 21 Jun 2024 10:27:51 +0200 Subject: [PATCH 11/89] Fix build --- apps/example/tailwind.config.js | 2 +- packages/next-admin/package.json | 12 +----------- packages/next-admin/src/appRouter.ts | 6 ++---- .../next-admin/src/{pageRouter.tsx => pageRouter.ts} | 1 - packages/next-admin/tsconfig.json | 8 ++++---- yarn.lock | 2 +- 6 files changed, 9 insertions(+), 22 deletions(-) rename packages/next-admin/src/{pageRouter.tsx => pageRouter.ts} (99%) diff --git a/apps/example/tailwind.config.js b/apps/example/tailwind.config.js index 10507115..66d1ac9e 100644 --- a/apps/example/tailwind.config.js +++ b/apps/example/tailwind.config.js @@ -146,5 +146,5 @@ module.exports = { }, ], plugins: [require("@headlessui/tailwindcss")], - presets: [require("@premieroctet/next-admin/tailwind-preset")], + presets: [require("@premieroctet/next-admin/dist/preset")], }; diff --git a/packages/next-admin/package.json b/packages/next-admin/package.json index 24aad139..04b1f682 100644 --- a/packages/next-admin/package.json +++ b/packages/next-admin/package.json @@ -1,17 +1,7 @@ { "name": "@premieroctet/next-admin", "version": "4.4.4", - "type": "module", - "exports": { - ".": { - "import": "./dist/index.js", - "require": "./dist/index.js" - }, - "./tailwind-preset": { - "import": "./dist/preset.js", - "require": "./dist/preset.js" - } - }, + "main": "./dist/index.js", "description": "Next-Admin provides a customizable and turnkey admin dashboard for applications built with Next.js and powered by the Prisma ORM. It aims to simplify the development process by providing a turnkey admin system that can be easily integrated into your project.", "keywords": [ "next.js", diff --git a/packages/next-admin/src/appRouter.ts b/packages/next-admin/src/appRouter.ts index e9fd6d9c..4521c356 100644 --- a/packages/next-admin/src/appRouter.ts +++ b/packages/next-admin/src/appRouter.ts @@ -2,7 +2,5 @@ import { GetPropsFromParamsParams } from "./types"; import { getPropsFromParams as _getPropsFromParams } from "./utils/props"; -export const getNextAdminProps = ({ - isAppDir = true, - ...params -}: GetPropsFromParamsParams) => _getPropsFromParams({ ...params, isAppDir }); +export const getNextAdminProps = (params: GetPropsFromParamsParams) => + _getPropsFromParams({ ...params, isAppDir: true }); diff --git a/packages/next-admin/src/pageRouter.tsx b/packages/next-admin/src/pageRouter.ts similarity index 99% rename from packages/next-admin/src/pageRouter.tsx rename to packages/next-admin/src/pageRouter.ts index 256cacee..846f619e 100644 --- a/packages/next-admin/src/pageRouter.tsx +++ b/packages/next-admin/src/pageRouter.ts @@ -1,5 +1,4 @@ import { PrismaClient } from "@prisma/client"; - import { IncomingMessage } from "node:http"; import { NextAdminOptions } from "./types"; import { getPropsFromParams } from "./utils/props"; diff --git a/packages/next-admin/tsconfig.json b/packages/next-admin/tsconfig.json index a30ce3ad..eba795c4 100644 --- a/packages/next-admin/tsconfig.json +++ b/packages/next-admin/tsconfig.json @@ -1,15 +1,15 @@ { "extends": "tsconfig/react-library.json", "include": [ - "./src/appRouter.ts", + "./src/pageRouter.ts", "./src/index.tsx", - "./src/router.tsx", + "./src/appRouter.ts", "./src/actions/index.ts", "./src/mainLayout.tsx", "./src/plugin.ts", "./src/preset.ts", - "./src/handler.ts", - "./src/apiRoute.ts" + "./src/appHandler.ts", + "./src/pageHandler.ts" ], "exclude": ["dist", "build", "node_modules"], "compilerOptions": { diff --git a/yarn.lock b/yarn.lock index cb57e652..51f72b3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7511,7 +7511,7 @@ negotiator@^0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== -next-connect@^1.0.0: +next-connect@^1.0.0-next.3: version "1.0.0" resolved "https://registry.yarnpkg.com/next-connect/-/next-connect-1.0.0.tgz#49361a92b2d22db1cce73f94dfe793cd4b9e0106" integrity sha512-FeLURm9MdvzY1SDUGE74tk66mukSqL6MAzxajW7Gqh6DZKBZLrXmXnGWtHJZXkfvoi+V/DUe9Hhtfkl4+nTlYA== From 6ad85b4c7c8c9ff68fc789d1ec4eb040da1ed982 Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Fri, 21 Jun 2024 11:05:49 +0200 Subject: [PATCH 12/89] Fix test e2e --- apps/example/e2e/004-custom_actions.spec.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/example/e2e/004-custom_actions.spec.ts b/apps/example/e2e/004-custom_actions.spec.ts index 4a80f286..1db401b3 100644 --- a/apps/example/e2e/004-custom_actions.spec.ts +++ b/apps/example/e2e/004-custom_actions.spec.ts @@ -14,8 +14,13 @@ test.describe("User's custom actions", () => { await page.getByTestId("actions-dropdown").click(); await expect(page.getByText("Send email")).toBeVisible(); + const response = page.waitForResponse( + (response) => + response.url().includes("/submit-email") && response.status() === 200 + ); await page.getByText("Send email").click(); - await page.waitForURL((url) => !!url.searchParams.get("message")); + await response; + await expect(page.getByText("Email sent successfully")).toBeVisible(); }); @@ -34,12 +39,15 @@ test.describe("User's custom actions", () => { page.getByTestId("actions-dropdown-content").getByText("Delete") ).toBeVisible(); + const response = page.waitForResponse( + (response) => + response.request().method() === "DELETE" && response.status() === 200 + ); await page .getByTestId("actions-dropdown-content") .getByText("Delete") .click(); - - await page.waitForURL((url) => !!url.searchParams.get("message")); + await response; await expect(page.getByText("Deleted successfully")).toBeVisible(); await expect(page.locator("table tbody tr")).toHaveCount(3); }); From a005fdf59651ac963958063039cd36bca2ef89ab Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Fri, 21 Jun 2024 18:05:37 +0200 Subject: [PATCH 13/89] Change Documentation --- .changeset/strong-cobras-look.md | 5 + apps/docs/pages/docs/api-docs.mdx | 132 +++----- apps/docs/pages/docs/code-snippets.mdx | 42 ++- apps/docs/pages/docs/getting-started.mdx | 291 ++++++++---------- apps/docs/pages/docs/route.mdx | 8 +- .../[locale]/admin/[[...nextadmin]]/page.tsx | 11 +- .../app/api/admin/[[...nextadmin]]/route.ts | 1 + apps/example/options.tsx | 3 +- .../api/pagerouter/admin/[[...nextadmin]].ts | 1 + .../pagerouter/admin/[[...nextadmin]].tsx | 10 +- .../pages/pagerouter/admin/custom/index.tsx | 2 + packages/next-admin/src/appHandler.ts | 59 +--- .../next-admin/src/components/MainLayout.tsx | 3 +- .../next-admin/src/components/NextAdmin.tsx | 2 + packages/next-admin/src/handlers/options.ts | 2 +- packages/next-admin/src/handlers/resources.ts | 10 +- packages/next-admin/src/pageHandler.ts | 23 +- packages/next-admin/src/pageRouter.ts | 24 +- packages/next-admin/src/types.ts | 90 +++++- packages/next-admin/src/utils/options.ts | 4 +- packages/next-admin/src/utils/prisma.ts | 8 +- packages/next-admin/src/utils/props.ts | 42 +-- packages/next-admin/src/utils/server.ts | 2 +- 23 files changed, 392 insertions(+), 383 deletions(-) create mode 100644 .changeset/strong-cobras-look.md diff --git a/.changeset/strong-cobras-look.md b/.changeset/strong-cobras-look.md new file mode 100644 index 00000000..a9851123 --- /dev/null +++ b/.changeset/strong-cobras-look.md @@ -0,0 +1,5 @@ +--- +"@premieroctet/next-admin": major +--- + +API router implementation diff --git a/apps/docs/pages/docs/api-docs.mdx b/apps/docs/pages/docs/api-docs.mdx index d240323f..f4af86e3 100644 --- a/apps/docs/pages/docs/api-docs.mdx +++ b/apps/docs/pages/docs/api-docs.mdx @@ -8,60 +8,36 @@ import { Tabs } from "nextra/components"; The following is used only for App router. - ## `getPropsFromParams` function + ## `getNextAdminProps` function - `getPropsFromParams` is a function that returns the props for the [`NextAdmin`](#nextadmin--component) component. It accepts one argument which is an object with the following properties: + `getNextAdminProps` is a function that returns the props for the [`NextAdmin`](#nextadmin--component) component. It accepts one argument which is an object with the following properties: - `params`: the array of route params retrieved from the [optional catch-all segment](https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes#optional-catch-all-segments) - `searchParams`: the query params [retrieved from the page](https://nextjs.org/docs/app/api-reference/file-conventions/page#searchparams-optional) - - `options`: the [options](#next-admin-options) object + - `basePath`: the base path of your admin. It is used to build the admin URL. It is optional and defaults to `/admin` + - `apiBasePath`: the base path of your admin API. It is used to build the admin API URL. It is optional and defaults to `/api/admin` - `schema`: the json schema generated by the `prisma generate` command - `prisma`: your Prisma client instance - `getMessages`: a function with no parameters that returns translation messages. It is used to translate the default messages of the library. See [i18n](/docs/i18n) for more details. + - `locale`: the locale used, find from params (e.g. `en`, `fr`) + - `options`: the [options](#next-admin-options) object - optional The following is used only for Page router - ## `nextAdminRouter` function + ## `getNextAdminProps` function - `nextAdminRouter` is a function that returns a promise of a _Node Router_ that you can use in your getServerSideProps function to start using Next Admin. Its usage is only related to Page router. + `getNextAdminProps` is a function that returns the props for the [`NextAdmin`](#nextadmin--component) component. It accepts one argument which is an object with the following properties: - Usage example: - - ```ts copy - // pages/api/admin/[[...nextadmin]].ts - export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { - const { nextAdminRouter } = await import( - "@premieroctet/next-admin/dist/router" - ); - const adminRouter = await nextAdminRouter(prisma, schema); - return adminRouter.run(req, res) as Promise< - GetServerSidePropsResult<{ [key: string]: any }> - >; - }; - ``` - - It takes 3 parameters: - - - Your Prisma client instance, _required_ - - Your Prisma schema, _required_ - - and an _optional_ object of type [`NextAdminOptions`](#next-admin-options) to customize your admin with the following properties: - - ```ts - import { NextAdminOptions } from "@premieroctet/next-admin"; - - const options: NextAdminOptions = { - model: { - User: { - toString: (user) => `${user.email} / ${user.name}`, - }, - }, - }; - - const adminRouter = await nextAdminRouter(prisma, schema, options); - ``` + - `basePath`: the base path of your admin. It is used to build the admin URL. It is optional and defaults to `/admin` + - `apiBasePath`: the base path of your admin API. It is used to build the admin API URL. It is optional and defaults to `/api/admin` + - `schema`: the json schema generated by the `prisma generate` command + - `prisma`: your Prisma client instance + - `req`: the request object from the page (`IncomingMessage`) + - `getMessages`: a function with no parameters that returns translation messages. It is used to translate the default messages of the library. See [i18n](/docs/i18n) for more details. + - `locale`: the locale used, find from params (e.g. `en`, `fr`) + - `options`: the [options](#next-admin-options) object - optional @@ -76,9 +52,10 @@ import { Tabs } from "nextra/components"; ```ts // app/api/admin/[[...nextadmin]]/route.ts - const { run } = createAppHandler({ + const { run } = createHandler({ options, prisma, + apiBasePath: "/api/admin", schema, onRequest: (req) => { const session = await getServerSession(authOptions); @@ -109,10 +86,11 @@ import { Tabs } from "nextra/components"; redirect('/', { permanent: false }) } - const props = await getPropsFromParams({ + const props = await getNextAdminProps({ params: params.nextadmin, searchParams, - options, + basePath: "/admin", + apiBasePath: "/api/admin", prisma, schema, }); @@ -134,9 +112,10 @@ import { Tabs } from "nextra/components"; }, }; - const { run } = createApiRouter({ - options, + const { run } = createHandler({ prisma, + options, + apiBasePath: "/api/admin", schema: schema, onRequest: (req, res, next) => { const session = await getServerSession(req, res, authOptions); @@ -168,10 +147,14 @@ import { Tabs } from "nextra/components"; }; } - const { nextAdminRouter } = await import( - "@premieroctet/next-admin/dist/nextAdminRouter" - ); - return nextAdminRouter(client).run(req, res); + return await getNextAdminProps({ + basePath: "/pagerouter/admin", + apiBasePath: "/api/pagerouter/admin", + prisma, + schema, + options, + req, + }); }; ``` @@ -180,52 +163,31 @@ import { Tabs } from "nextra/components"; ## `` component -`` is a React component that contains the entire UI of Next Admin. It can take several props: +`` is a React component that contains the entire UI of Next Admin. It can take several props from `getNextAdminProps` function, but also some additional props: -- `AdminComponentProps`, which are passed by the [router function](#nextadminrouter-function) via getServerSideProps - `options` used to customize the UI, like field formatters for example. Do not use with App router. - `dashboard` used to customize the rendered dashboard -- `translations` used to customize some of the texts displayed in the UI. See [i18n](/docs/i18n) for more details. - `user` used to add some user information at the bottom of the menu. See [user properties](#user-properties) for more details. -> ⚠️ : Do not override these `AdminComponentProps` props, they are used internally by Next Admin. - This is an example of using the `NextAdmin` component with a custom Dashboard component and options: ```tsx -// pages/admin/[[...nextadmin]].tsx +// pages/admin/[[...nextadmin]]/page.tsx import Dashboard from "../../components/CustomDashboard"; export default function Admin(props: AdminComponentProps) { - /* Props are passed from the nextAdminRouter function via getServerSideProps */ + /* Props are passed from getNextAdminProps function */ return ( { - return {role.toString()}; - }, - }, - birthDate: { - formatter: (date) => { - return new Date(date as unknown as string) - ?.toLocaleString() - .split(" ")[0]; - }, - }, - }, - }, - }, + user={{ + data: { + name: "John Doe", }, + logoutUrl: "/api/auth/logout", }} + /> ); } @@ -233,11 +195,9 @@ export default function Admin(props: AdminComponentProps) { ## Next Admin Options -Next Admin options is the third parameter of the [router function](#nextadminrouter-function) and it's an object of options that has the following properties: +### `title` -### `basePath` - -`basePath` is a string that represents the base path of your admin. (e.g. `/admin`) - optional. +The `title` property is a string that represents the title of the admin dashboard. It is displayed in the sidebar. By default, it is set to "Admin". ### `model` @@ -338,7 +298,7 @@ For the `edit` property, it can take the following: | `display` | only for relation fields, indicate which display format to use between `list`, `table` or `select`. Default `select` | | `required` | a true value to force a field to be required in the form, note that if the field is required by the Prisma schema, you cannot set `required` to false | | `relationOptionFormatter` | same as `optionFormatter`, but used to format data that comes from an [explicit many-to-many](https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/many-to-many-relations#explicit-many-to-many-relations) relationship. See [handling explicit many-to-many](/docs/code-snippets#explicit-many-to-many) | -| `orderField` | the field to use for relationship sorting. This allow to drag and drop the related records in the `list` display. | +| `orderField` | the field to use for relationship sorting. This allow to drag and drop the related records in the `list` display. | | `relationshipSearchField` | a field name of the explicit many-to-many relation table to apply the search on. See [handling explicit many-to-many](/docs/code-snippets#explicit-many-to-many) | ##### `filters` property @@ -434,8 +394,8 @@ model User { ``` ```tsx -// pages/api/admin/[[...nextadmin]].ts -const options: NextAdminOptions = { +// pages/api/admin/options.ts +export const options: NextAdminOptions = { basePath: "/admin", model: { User: { @@ -480,8 +440,6 @@ const options: NextAdminOptions = { }, }, }; - -const adminRouter = await nextAdminRouter(prisma, schema, options); ``` ## CustomInputProps diff --git a/apps/docs/pages/docs/code-snippets.mdx b/apps/docs/pages/docs/code-snippets.mdx index 90182a13..45610018 100644 --- a/apps/docs/pages/docs/code-snippets.mdx +++ b/apps/docs/pages/docs/code-snippets.mdx @@ -89,24 +89,36 @@ There is two example files in the example project: If you want to add data to the form data before submitting it, you can add logic to the `submitFormAction` function. This is an example of how to add `createdBy` and `updatedBy` fields based on the user id: ```typescript copy -// actions/nextadmin.ts -"use server"; -import { ActionParams } from "@premieroctet/next-admin"; -import { submitForm } from "@premieroctet/next-admin/dist/actions"; - -export const submitFormAction = async ( - params: ActionParams, - formData: FormData -) => { - const userId = /* get the user id */; - if (params.params[1] === "new") { - formData.append("createdBy", userId); +// /api/admin/[[...nextadmin]]/route.ts +import { options } from "@/options"; +import { prisma } from "@/prisma"; +import schema from "@/prisma/json-schema/json-schema.json"; +import { createHandler } from "@premieroctet/next-admin/dist/appHandler"; +import { NextRequest } from "next/server"; + +const { run } = createHandler({ + apiBasePath: "/api/admin", + options, + prisma, + schema, +}); + +export async function POST(req: NextRequest, context: any) { + const userId = 1; + const formData = await req.formData(); + if (context.params.nextadmin[1] === "new") { + formData.append("createdBy", userId.toString()); } else { - formData.append("updatedBy", userId); + formData.append("updatedBy", userId.toString()); } + req.formData = async () => formData; + + return run(req, context); +} + +export { run as DELETE, run as GET }; + - return submitForm({ ...params, options, prisma }, formData); -}; ``` > Note that this example assumes that you have a `createdBy` and `updatedBy` field on each model, if you need to check the model name, you can use `params.params[0]`. diff --git a/apps/docs/pages/docs/getting-started.mdx b/apps/docs/pages/docs/getting-started.mdx index 9ad34b59..915c5329 100644 --- a/apps/docs/pages/docs/getting-started.mdx +++ b/apps/docs/pages/docs/getting-started.mdx @@ -1,33 +1,36 @@ -import { Callout, Tabs } from "nextra/components"; +import { Callout, Steps, Tabs } from "nextra/components"; # Getting Started -## Installation +The following guide will help you get started with Next-Admin. + + +### Installation - ```bash + ```bash copy yarn add @premieroctet/next-admin prisma-json-schema-generator ``` - ```bash + ```bash copy npm install -S @premieroctet/next-admin prisma-json-schema-generator ``` - ```bash + ```bash copy pnpm install -S @premieroctet/next-admin prisma-json-schema-generator ``` -## TailwindCSS configuration +### TailwindCSS Next-Admin relies on [TailwindCSS](https://tailwindcss.com/) for the style. If you do not have it, you can [install TailwindCSS](https://tailwindcss.com/docs/installation) with the following config : -```typescript +```typescript copy module.exports = { content: [ "./node_modules/@premieroctet/next-admin/dist/**/*.{js,ts,jsx,tsx}", @@ -41,84 +44,14 @@ Then import your `.css` file containing Tailwind rules into a page file or a par You can find more information about theming [here](/docs/theming) -## SuperJson configuration - -SuperJson is required to avoid errors related to invalid serialisation properties that can occur when passing data from server to client. - -### With Babel - - - - ```bash -yarn add -D babel-plugin-superjson-next superjson@^1 - ``` - - - - ```bash -npm install --save-dev babel-plugin-superjson-next superjson@^1 - ``` - - - ```bash -pnpm install -D babel-plugin-superjson-next superjson@^1 - ``` - - - -Add the `babel-plugin-superjson-next` plugin to your `.babelrc` file: - -```json -{ - "presets": ["next/babel"], - "plugins": ["superjson-next"] -} -``` - -## With SWC (Experimental) - - - - ```bash -yarn add -E -D next-superjson-plugin@0.6.1 superjson - ``` - - - - ```bash -npm install --save-dev -E next-superjson-plugin@0.6.1 superjson - ``` - - - ```bash -pnpm install -E -D next-superjson-plugin@0.6.1 superjson - ``` - - - -Add the `next-superjson-plugin` plugin to your `next.config.js` file: +### Prisma -```js -module.exports = { - // your current config - experimental: { - swcPlugins: [ - [ - "next-superjson-plugin", - { - excluded: [], - }, - ], - ], - }, -}; -``` - -## Quick Start +Next-Admin relies on [Prisma](https://www.prisma.io/) for the database. If you do not have it, you can [install Prisma](https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch-typescript-postgres) with the following config. -Add the `prisma-json-schema-generator` generator to your `schema.prisma` file: +You have to add the `jsonSchema` generator to your `schema.prisma` file: -```prisma +```prisma copy +// prisma/schema.prisma generator jsonSchema { provider = "prisma-json-schema-generator" includeRequiredFields = "true" @@ -127,57 +60,42 @@ generator jsonSchema { Then run the following command : -```bash +```bash copy yarn run prisma generate ``` -This will create a `json-schema.json` file in the `prisma/json-schema` directory. +### Page `[[...nextadmin]]` + +Next-Admin uses a dynamic route `[[...nextadmin]]` to handle all the admin routes. - ```ts - // app/api/admin/[[...nextadmin]]/route.ts - import { createAppHandler } from "@premieroctet/next-admin/dist/handler"; - import schema from "@/prisma/json-schema/json-schema.json"; - import { prisma } from "@/prisma"; - import { options } from "@/options"; - - const { run } = createAppHandler({ - options, - prisma, - schema, - }); + Create file : `app/api/admin/[[...nextadmin]]/route.ts` - export { run as POST, run as GET, run as DELETE }; - ``` - - ```tsx + ```tsx copy // app/admin/[[...nextadmin]]/page.tsx - import { NextAdmin } from "@premieroctet/next-admin"; - import { getPropsFromParams } from "@premieroctet/next-admin/dist/appRouter"; - import Dashboard from "../../../components/Dashboard"; - import { options } from "../../../options"; - import { prisma } from "../../../prisma"; - import schema from "../../../prisma/json-schema/json-schema.json"; - import "../../../styles.css" // .css file containing tailiwnd rules + import { NextAdmin, PageProps } from "@premieroctet/next-admin"; + import { getNextAdminProps } from "@premieroctet/next-admin/dist/appRouter"; + import { prisma } from "@/prisma"; + import schema from "@/prisma/json-schema/json-schema.json"; + import "@/styles.css" // .css file containing tailiwnd rules export default async function AdminPage({ params, searchParams, - }: { - params: { [key: string]: string[] }; - searchParams: { [key: string]: string | string[] | undefined } | undefined; - }) { - const props = await getPropsFromParams({ + }: PageProps) { + const props = await getNextAdminProps({ params: params.nextadmin, searchParams, - options, + basePath: "/admin", + apiBasePath: "/api/admin", prisma, schema, + /*options*/ }); - return ; + return ; } ``` @@ -185,79 +103,138 @@ This will create a `json-schema.json` file in the `prisma/json-schema` directory Passing the `options` prop like you'd do on Page router will result in an error in case you have functions defined inside the options object (formatter, handlers, etc.). - Make sure to pass no `options` prop at all. + Make sure to pass no `options` prop to `NextAdmin` component in App router. - + + - Make sure to not use `"use client"` in the page. + Create file : `pages/api/admin/[[...nextadmin]].ts` - + ```tsx copy + // pages/admin/[[...nextadmin]].tsx + import { AdminComponentProps, NextAdmin } from "@premieroctet/next-admin"; + + import { getNextAdminProps } from "@premieroctet/next-admin/dist/pageRouter"; + import { GetServerSideProps } from "next"; + import { prisma } from " @/prisma"; + import schema from "@/prisma/json-schema/json-schema.json"; + import "@/styles.css"; + + export default function Admin(props: AdminComponentProps) { + return ( + + ); + } + + export const getServerSideProps: GetServerSideProps = async ({ req }) => + await getNextAdminProps({ + basePath: "/pagerouter/admin", + apiBasePath: "/api/pagerouter/admin", + prisma, + schema, + /*options*/ + req, + }); + ``` + + + Do not forget to add the `options` prop to the `NextAdmin` component. + + + + +### API Route `[[...nextadmin]]` + +Next-Admin uses a dynamic route `[[...nextadmin]]` to handle all the API routes. + + - ```ts - // pages/api/admin/[[...nextadmin]].ts - import { options } from "@/pageRouterOptions"; + ```ts copy + // app/api/admin/[[...nextadmin]]/route.ts import { prisma } from "@/prisma"; - import { createApiRouter } from "@premieroctet/next-admin/dist/apiRoute"; import schema from "@/prisma/json-schema/json-schema.json"; + import { createHandler } from "@premieroctet/next-admin/dist/appHandler"; - export const config = { - api: { - bodyParser: false, - }, - }; - - const { run } = createApiRouter({ - options, + const { run } = createHandler({ + apiBasePath: "/api/admin", prisma, - schema: schema, + schema, + /*options*/ }); - export default run; + export { run as DELETE, run as GET, run as POST }; ``` - Make sure to export the config object to define no `bodyParser`. This is required to be able to parse FormData. + Passing the `options` prop like you'd do on Page router will result in an error in case you + have functions defined inside the options object (formatter, handlers, etc.). + Make sure to pass no `options` prop at all. + + + + + + Make sure to not use `"use client"` in the page. - ```tsx - // pages/admin/[[...nextadmin]].tsx - import { GetServerSideProps, GetServerSidePropsResult } from "next"; - import { NextAdmin, AdminComponentProps } from "@premieroctet/next-admin"; - import schema from "./../../prisma/json-schema/json-schema.json"; // import the json-schema.json file - import { PrismaClient } from "@prisma/client"; - import "../../../styles.css" // .css file containing tailiwnd rules + + - const prisma = new PrismaClient(); + ```ts copy + // pages/api/admin/[[...nextadmin]].ts + import { prisma } from "@/prisma"; + import { createApiRouter } from "@premieroctet/next-admin/dist/pageHandler"; + import schema from "@/prisma/json-schema/json-schema.json"; - export default function Admin(props: AdminComponentProps) { - return ; - } + export const config = { + api: { + bodyParser: false, + }, + }; - export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { - const { nextAdminRouter } = await import( - "@premieroctet/next-admin/dist/router" - ); - const adminRouter = await nextAdminRouter(prisma, schema); - return adminRouter.run(req, res) as Promise< - GetServerSidePropsResult<{ [key: string]: any }> - >; - }; - ``` + const { run } = createHandler({ + apiBasePath: "/api/admin", + prisma, + schema: schema, + /*options*/, + }); + + export default run; + ``` + + + + Make sure to export the config object to define no `bodyParser`. This is required to be able to parse FormData. -The `nextAdminRouter` function accepts a third optional parameter, which is a Next Admin [options](/docs/api-docs#next-admin-options) object. + + + The `nextAdminRouter` function accepts a third optional parameter, which is a Next Admin [options](/docs/api-docs#next-admin-options) object. +### Next Admin options - optional + +The `NextAdmin` component accepts an optional `options` prop. In the blocks above, you can see that the `options` prop is commented out. It may be useful to centralize the options in a single file. More information about the options [here](/docs/api-docs#next-admin-options). + + + The `options` parameter can be set to function/component, if you are using + options, be sure to pass the same options to the handler and the router + function + + + + ## Usage Once done, you can navigate to the `/admin` route. - -You should be able to see the admin dashboard. diff --git a/apps/docs/pages/docs/route.mdx b/apps/docs/pages/docs/route.mdx index 33e118ff..fa75c658 100644 --- a/apps/docs/pages/docs/route.mdx +++ b/apps/docs/pages/docs/route.mdx @@ -5,6 +5,10 @@ The admin panel route name is set by your folder name. Examples: -`pages/admin/[[...nextadmin]].tsx` will be `/admin` +`pages/admin/[[...nextadmin]].tsx` => `basePath` will be `/admin` (Page Router) -`pages/prisma/[[...nextadmin]].tsx` will be `/prisma` +`pages/api/admin/[[...nextadmin]].ts` => `apiBasePath` will be `/api/admin` (API Page Router) + +`pages/prisma/[[...nextadmin]]/page.tsx` => `basePath` will be `/admin` (App Router) + +`pages/prisma/[[...nextadmin]]/route.ts` => `apiBasePath` will be `/api/admin` (API App Router) diff --git a/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx b/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx index aeb44fcb..febf6ccd 100644 --- a/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx +++ b/apps/example/app/[locale]/admin/[[...nextadmin]]/page.tsx @@ -1,3 +1,4 @@ +import Dashboard from "@/components/Dashboard"; import { options } from "@/options"; import { prisma } from "@/prisma"; import schema from "@/prisma/json-schema/json-schema.json"; @@ -15,12 +16,17 @@ export const metadata: Metadata = { icons: "/favicon.ico", }; -export default async function AdminPage({ params, searchParams }: PageProps) { +export default async function AdminPage({ + params, + searchParams, +}: Readonly) { const props = await getNextAdminProps({ params: params.nextadmin as string[], searchParams, - options, + basePath: "/admin", + apiBasePath: "/api/admin", prisma, + options, schema, getMessages: (locale) => getMessages({ locale }).then( @@ -32,6 +38,7 @@ export default async function AdminPage({ params, searchParams }: PageProps) { return ( } user={{ data: { name: "John Doe", diff --git a/apps/example/app/api/admin/[[...nextadmin]]/route.ts b/apps/example/app/api/admin/[[...nextadmin]]/route.ts index 13720cae..c259aee5 100644 --- a/apps/example/app/api/admin/[[...nextadmin]]/route.ts +++ b/apps/example/app/api/admin/[[...nextadmin]]/route.ts @@ -4,6 +4,7 @@ import schema from "@/prisma/json-schema/json-schema.json"; import { createHandler } from "@premieroctet/next-admin/dist/appHandler"; const { run } = createHandler({ + apiBasePath: "/api/admin", options, prisma, schema, diff --git a/apps/example/options.tsx b/apps/example/options.tsx index f70cfa72..49c5c675 100644 --- a/apps/example/options.tsx +++ b/apps/example/options.tsx @@ -2,9 +2,8 @@ import { NextAdminOptions } from "@premieroctet/next-admin"; import DatePicker from "./components/DatePicker"; export const options: NextAdminOptions = { - basePath: "/admin", - apiBasePath: "/api/admin", title: "⚡️ My Admin", + model: { User: { toString: (user) => `${user.name} (${user.email})`, diff --git a/apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts b/apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts index be8318d4..ccd285df 100644 --- a/apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts +++ b/apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts @@ -10,6 +10,7 @@ export const config = { }; const { run } = createHandler({ + apiBasePath: "/api/admin", options, prisma, schema: schema, diff --git a/apps/example/pages/pagerouter/admin/[[...nextadmin]].tsx b/apps/example/pages/pagerouter/admin/[[...nextadmin]].tsx index 4c1fa28e..2ceaf0b6 100644 --- a/apps/example/pages/pagerouter/admin/[[...nextadmin]].tsx +++ b/apps/example/pages/pagerouter/admin/[[...nextadmin]].tsx @@ -24,4 +24,12 @@ export default function Admin(props: AdminComponentProps) { } export const getServerSideProps: GetServerSideProps = async ({ req }) => - await getNextAdminProps(prisma, schema, pageOptions, req); + await getNextAdminProps({ + basePath: "/pagerouter/admin", + apiBasePath: "/api/pagerouter/admin", + prisma, + schema, + options: pageOptions, + req, + + }); diff --git a/apps/example/pages/pagerouter/admin/custom/index.tsx b/apps/example/pages/pagerouter/admin/custom/index.tsx index 8f7092d1..4c8d66ef 100644 --- a/apps/example/pages/pagerouter/admin/custom/index.tsx +++ b/apps/example/pages/pagerouter/admin/custom/index.tsx @@ -72,6 +72,8 @@ export const getServerSideProps: GetServerSideProps = async ({ req, }) => { const mainLayoutProps = getMainLayoutProps({ + basePath: "/pagerouter/admin", + apiBasePath: "/api/pagerouter/admin", options: pageOptions, isAppDir: false, }); diff --git a/packages/next-admin/src/appHandler.ts b/packages/next-admin/src/appHandler.ts index 69506a56..121e935f 100644 --- a/packages/next-admin/src/appHandler.ts +++ b/packages/next-admin/src/appHandler.ts @@ -1,9 +1,8 @@ -import { PrismaClient } from "@prisma/client"; import { createEdgeRouter } from "next-connect"; import { NextRequest, NextResponse } from "next/server"; import { handleOptionsSearch } from "./handlers/options"; import { deleteResource, submitResource } from "./handlers/resources"; -import { NextAdminOptions, Permission } from "./types"; +import { CreateAppHandlerParams, Permission, RequestContext } from "./types"; import { hasPermission } from "./utils/permissions"; import { formatId, @@ -12,46 +11,8 @@ import { getResources, } from "./utils/server"; -type RequestContext

= { - params: Record; -}; - -type CreateAppHandlerParams

= { - /** - * Next-admin options - */ - options: NextAdminOptions; - /** - * Prisma client instance - */ - prisma: PrismaClient; - /** - * A function that acts as a middleware. Useful to add authentication logic for example. - */ - onRequest?: ( - req: NextRequest, - ctx: RequestContext

- ) => - | ReturnType - | ReturnType - | Promise; - /** - * A string indicating the name of the dynamic segment. - * - * Example: - * - If the dynamic segment is `[[...nextadmin]]`, then the `paramKey` should be `nextadmin`. - * - If the dynamic segment is `[[...admin]]`, then the `paramKey` should be `admin`. - * - * @default "nextadmin" - */ - paramKey?: P; - /** - * Generated JSON schema from Prisma - */ - schema: any; -}; - export const createHandler =

({ + apiBasePath, options, prisma, paramKey = "nextadmin" as P, @@ -74,7 +35,7 @@ export const createHandler =

({ } router - .post(`${options.apiBasePath}/:model/actions/:id`, async (req, ctx) => { + .post(`${apiBasePath}/:model/actions/:id`, async (req, ctx) => { const id = ctx.params[paramKey].at(-1)!; // Make sure we don't have a false positive with a model that could be named actions @@ -90,7 +51,7 @@ export const createHandler =

({ ); } - const modelAction = options.model?.[resource]?.actions?.find( + const modelAction = options?.model?.[resource]?.actions?.find( (action) => action.id === id ); @@ -114,13 +75,13 @@ export const createHandler =

({ ); } }) - .post(`${options.apiBasePath}/options`, async (req, ctx) => { + .post(`${apiBasePath}/options`, async (req, ctx) => { const body = await req.json(); const data = await handleOptionsSearch(body, prisma, options); return NextResponse.json(data); }) - .post(`${options.apiBasePath}/:model/:id?`, async (req, ctx) => { + .post(`${apiBasePath}/:model/:id?`, async (req, ctx) => { const resource = getResourceFromParams(ctx.params[paramKey], resources); if (!resource) { @@ -161,7 +122,7 @@ export const createHandler =

({ ); } }) - .delete(`${options.apiBasePath}/:model/:id`, async (req, ctx) => { + .delete(`${apiBasePath}/:model/:id`, async (req, ctx) => { const resource = getResourceFromParams(ctx.params[paramKey], resources); if (!resource) { @@ -171,7 +132,7 @@ export const createHandler =

({ ); } - if (!hasPermission(options.model?.[resource], Permission.DELETE)) { + if (!hasPermission(options?.model?.[resource], Permission.DELETE)) { return NextResponse.json( { error: "You don't have permission to delete this resource" }, { status: 403 } @@ -186,7 +147,7 @@ export const createHandler =

({ return NextResponse.json({ ok: true }); }) - .delete(`${options.apiBasePath}/:model`, async (req, ctx) => { + .delete(`${apiBasePath}/:model`, async (req, ctx) => { const resource = getResourceFromParams(ctx.params[paramKey], resources); if (!resource) { @@ -196,7 +157,7 @@ export const createHandler =

({ ); } - if (!hasPermission(options.model?.[resource], Permission.DELETE)) { + if (!hasPermission(options?.model?.[resource], Permission.DELETE)) { return NextResponse.json( { error: "You don't have permission to delete this resource" }, { status: 403 } diff --git a/packages/next-admin/src/components/MainLayout.tsx b/packages/next-admin/src/components/MainLayout.tsx index 96985a1c..b4fd9dbc 100644 --- a/packages/next-admin/src/components/MainLayout.tsx +++ b/packages/next-admin/src/components/MainLayout.tsx @@ -25,6 +25,7 @@ export const MainLayout = ({ externalLinks, title, options, + apiBasePath }: PropsWithChildren) => { const mergedTranslations = merge({ ...defaultTranslations }, translations); const localePath = locale ? `/${locale}` : ""; @@ -34,7 +35,7 @@ export const MainLayout = ({ options={options} basePath={`${localePath}${basePath}`} isAppDir={isAppDir} - apiBasePath={options!.apiBasePath} + apiBasePath={apiBasePath} > diff --git a/packages/next-admin/src/components/NextAdmin.tsx b/packages/next-admin/src/components/NextAdmin.tsx index 66079b53..e9ae635f 100644 --- a/packages/next-admin/src/components/NextAdmin.tsx +++ b/packages/next-admin/src/components/NextAdmin.tsx @@ -11,6 +11,7 @@ import PageLoader from "./PageLoader"; // Components export function NextAdmin({ basePath, + apiBasePath, data, resource, schema, @@ -110,6 +111,7 @@ export function NextAdmin({ resourcesTitles={resourcesTitles} customPages={customPages} basePath={basePath} + apiBasePath={apiBasePath} isAppDir={isAppDir} translations={translations} locale={locale} diff --git a/packages/next-admin/src/handlers/options.ts b/packages/next-admin/src/handlers/options.ts index 65f9661e..eb80c6ed 100644 --- a/packages/next-admin/src/handlers/options.ts +++ b/packages/next-admin/src/handlers/options.ts @@ -5,7 +5,7 @@ import { NextAdminOptions } from "../types"; export const handleOptionsSearch = ( body: any, prisma: PrismaClient, - options: NextAdminOptions + options?: NextAdminOptions ) => { const { originModel, property, model, query, page, perPage } = body; diff --git a/packages/next-admin/src/handlers/resources.ts b/packages/next-admin/src/handlers/resources.ts index b9574e7d..239520db 100644 --- a/packages/next-admin/src/handlers/resources.ts +++ b/packages/next-admin/src/handlers/resources.ts @@ -47,7 +47,7 @@ type SubmitResourceParams = { resource: ModelName; body: Record; id?: string | number; - options: NextAdminOptions; + options?: NextAdminOptions; schema: any; }; @@ -67,7 +67,7 @@ export const submitResource = async ({ let data; - const fields = options.model?.[resource]?.edit?.fields as EditFieldsOptions< + const fields = options?.model?.[resource]?.edit?.fields as EditFieldsOptions< typeof resource >; @@ -87,7 +87,7 @@ export const submitResource = async ({ if (errors.length) { return { error: - options.model?.[resource]?.edit?.submissionErrorMessage ?? + options?.model?.[resource]?.edit?.submissionErrorMessage ?? "Submission error", validation: errors.map((error) => ({ property: error.field, @@ -98,7 +98,7 @@ export const submitResource = async ({ // Edit if (!!id) { - if (!hasPermission(options.model?.[resource], Permission.EDIT)) { + if (!hasPermission(options?.model?.[resource], Permission.EDIT)) { return { error: "Unable to update items of this model", }; @@ -115,7 +115,7 @@ export const submitResource = async ({ return { updated: true, redirect: redirect === "list" }; } - if (!hasPermission(options.model?.[resource], Permission.CREATE)) { + if (!hasPermission(options?.model?.[resource], Permission.CREATE)) { return { error: "Unable to create items of this model", }; diff --git a/packages/next-admin/src/pageHandler.ts b/packages/next-admin/src/pageHandler.ts index c2d2f118..f43a2709 100644 --- a/packages/next-admin/src/pageHandler.ts +++ b/packages/next-admin/src/pageHandler.ts @@ -14,10 +14,14 @@ import { } from "./utils/server"; type CreateAppHandlerParams

= { + /** + * `apiBasePath` is a string that represents the base path of the admin API route. (e.g. `/api`) - optional. + */ + apiBasePath: string; /** * Next-admin options */ - options: NextAdminOptions; + options?: NextAdminOptions; /** * Prisma client instance */ @@ -47,6 +51,7 @@ type CreateAppHandlerParams

= { }; export const createHandler =

({ + apiBasePath, options, prisma, paramKey = "nextadmin" as P, @@ -61,7 +66,7 @@ export const createHandler =

({ } router - .post(`${options.apiBasePath}/:model/actions/:id`, async (req, res) => { + .post(`${apiBasePath}/:model/actions/:id`, async (req, res) => { const id = req.query[paramKey]!.at(-1)!; // Make sure we don't have a false positive with a model that could be named actions @@ -74,7 +79,7 @@ export const createHandler =

({ return res.status(404).json({ error: "Resource not found" }); } - const modelAction = options.model?.[resource]?.actions?.find( + const modelAction = options?.model?.[resource]?.actions?.find( (action) => action.id === id ); @@ -98,7 +103,7 @@ export const createHandler =

({ return res.status(500).json({ error: (e as Error).message }); } }) - .post(`${options.apiBasePath}/options`, async (req, res) => { + .post(`${apiBasePath}/options`, async (req, res) => { let body; try { @@ -111,7 +116,7 @@ export const createHandler =

({ return res.json(data); }) - .post(`${options.apiBasePath}/:model/:id?`, async (req, res) => { + .post(`${apiBasePath}/:model/:id?`, async (req, res) => { const resource = getResourceFromParams( [req.query[paramKey]![0]], resources @@ -149,7 +154,7 @@ export const createHandler =

({ return res.status(500).json({ error: (e as Error).message }); } }) - .delete(`${options.apiBasePath}/:model/:id`, async (req, res) => { + .delete(`${apiBasePath}/:model/:id`, async (req, res) => { const resource = getResourceFromParams( [req.query[paramKey]![0]], resources @@ -159,7 +164,7 @@ export const createHandler =

({ return res.status(404).json({ error: "Resource not found" }); } - if (!hasPermission(options.model?.[resource], Permission.DELETE)) { + if (!hasPermission(options?.model?.[resource], Permission.DELETE)) { return res.status(403).json({ error: "You don't have permission to delete this resource", }); @@ -177,7 +182,7 @@ export const createHandler =

({ return res.status(500).json({ error: (e as Error).message }); } }) - .delete(`${options.apiBasePath}/:model`, async (req, res) => { + .delete(`${apiBasePath}/:model`, async (req, res) => { const resource = getResourceFromParams( [req.query[paramKey]![0]], resources @@ -187,7 +192,7 @@ export const createHandler =

({ return res.status(404).json({ error: "Resource not found" }); } - if (!hasPermission(options.model?.[resource], Permission.DELETE)) { + if (!hasPermission(options?.model?.[resource], Permission.DELETE)) { return res.status(403).json({ error: "You don't have permission to delete this resource", }); diff --git a/packages/next-admin/src/pageRouter.ts b/packages/next-admin/src/pageRouter.ts index 846f619e..86eea58f 100644 --- a/packages/next-admin/src/pageRouter.ts +++ b/packages/next-admin/src/pageRouter.ts @@ -1,23 +1,28 @@ -import { PrismaClient } from "@prisma/client"; import { IncomingMessage } from "node:http"; -import { NextAdminOptions } from "./types"; +import { GetPropsFromParamsParams } from "./types"; import { getPropsFromParams } from "./utils/props"; import { formatSearchFields, getParamsFromUrl } from "./utils/server"; // Router -export const getNextAdminProps = async ( - prisma: PrismaClient, - schema: any, - options: NextAdminOptions, - req: IncomingMessage -) => { - const params = getParamsFromUrl(req.url!, options.basePath); +export const getNextAdminProps = async ({ + prisma, + schema, + basePath, + apiBasePath, + options, + req, +}: Omit & { + req: IncomingMessage; +}) => { + const params = getParamsFromUrl(req.url!, basePath); const requestOptions = formatSearchFields(req.url!); const props = await getPropsFromParams({ options, prisma, schema, + basePath, + apiBasePath, searchParams: requestOptions, params, isAppDir: false, @@ -25,3 +30,4 @@ export const getNextAdminProps = async ( return { props }; }; + diff --git a/packages/next-admin/src/types.ts b/packages/next-admin/src/types.ts index 6f6a18b1..aed7e1b5 100644 --- a/packages/next-admin/src/types.ts +++ b/packages/next-admin/src/types.ts @@ -1,6 +1,7 @@ import * as OutlineIcons from "@heroicons/react/24/outline"; import { Prisma, PrismaClient } from "@prisma/client"; import type { JSONSchema7 } from "json-schema"; +import { NextRequest, NextResponse } from "next/server"; import type { ChangeEvent, ReactNode } from "react"; import type { PropertyValidationError } from "./exceptions/ValidationError"; @@ -437,14 +438,6 @@ export type ExternalLink = { }; export type NextAdminOptions = { - /** - * `basePath` is a string that represents the base path of your admin. (e.g. `/admin`) - optional. - */ - basePath: string; - /** - * `apiBasePath` is a string that represents the base path of the admin API route. (e.g. `/api`) - optional. - */ - apiBasePath: string; /** * Global admin title * @@ -595,6 +588,7 @@ export type AdminUser = { export type AdminComponentProps = { basePath: string; + apiBasePath: string; schema?: Schema; data?: ListData; resource?: ModelName; @@ -646,6 +640,7 @@ export type MainLayoutProps = Pick< | "resourcesTitles" | "customPages" | "basePath" + | "apiBasePath" | "isAppDir" | "translations" | "locale" @@ -721,20 +716,83 @@ export const colorSchemes = ["light", "dark", "system"] as const; export type ColorScheme = (typeof colorSchemes)[number]; export type BasicColorScheme = Exclude; -export type PageProps = { - readonly params: { [key: string]: string[] | string }; - readonly searchParams: - | { [key: string]: string | string[] | undefined } - | undefined; -}; +export type PageProps = Readonly<{ + params: { [key: string]: string[] | string }; + searchParams: { [key: string]: string | string[] | undefined } | undefined; +}>; export type GetPropsFromParamsParams = { params?: string[]; searchParams: { [key: string]: string | string[] | undefined } | undefined; - options: NextAdminOptions; + /** + * `basePath` is a string that represents the base path of your admin. (e.g. `/admin`) + */ + basePath: string; + /** + * `apiBasePath` is a string that represents the base path of the admin API route. (e.g. `/api/admin`) + */ + apiBasePath: string; + options?: NextAdminOptions; schema: any; prisma: PrismaClient; isAppDir?: boolean; locale?: string; getMessages?: (locale: string) => Promise>; -}; \ No newline at end of file +}; + +export type GetMainLayoutPropsParams = { + /** + * `basePath` is a string that represents the base path of your admin. (e.g. `/admin`) - optional. + */ + basePath: string; + /** + * `apiBasePath` is a string that represents the base path of the admin API route. (e.g. `/api`) - optional. + */ + apiBasePath: string; + options?: NextAdminOptions; + params?: string[]; + isAppDir?: boolean; +}; + +export type RequestContext

= { + params: Record; +}; + +export type CreateAppHandlerParams

= { + /** + * `apiBasePath` is a string that represents the base path of the admin API route. (e.g. `/api`) - optional. + */ + apiBasePath: string; + /** + * Next-admin options + */ + options?: NextAdminOptions; + /** + * Prisma client instance + */ + prisma: PrismaClient; + /** + * A function that acts as a middleware. Useful to add authentication logic for example. + */ + onRequest?: ( + req: NextRequest, + ctx: RequestContext

+ ) => + | ReturnType + | ReturnType + | Promise; + /** + * A string indicating the name of the dynamic segment. + * + * Example: + * - If the dynamic segment is `[[...nextadmin]]`, then the `paramKey` should be `nextadmin`. + * - If the dynamic segment is `[[...admin]]`, then the `paramKey` should be `admin`. + * + * @default "nextadmin" + */ + paramKey?: P; + /** + * Generated JSON schema from Prisma + */ + schema: any; +}; diff --git a/packages/next-admin/src/utils/options.ts b/packages/next-admin/src/utils/options.ts index 107f2ada..99518786 100644 --- a/packages/next-admin/src/utils/options.ts +++ b/packages/next-admin/src/utils/options.ts @@ -7,9 +7,9 @@ import { Field, ModelName, NextAdminOptions } from "../types"; */ export const getCustomInputs = ( model: ModelName, - options: NextAdminOptions + options?: NextAdminOptions ) => { - const editFields = options.model?.[model]?.edit?.fields; + const editFields = options?.model?.[model]?.edit?.fields; return Object.keys(editFields ?? {}).reduce( (acc, field) => { diff --git a/packages/next-admin/src/utils/prisma.ts b/packages/next-admin/src/utils/prisma.ts index 0f5927b5..d8d5e53f 100644 --- a/packages/next-admin/src/utils/prisma.ts +++ b/packages/next-admin/src/utils/prisma.ts @@ -182,7 +182,7 @@ export const preparePrismaListRequest = ( type GetMappedDataListParams = { prisma: PrismaClient; resource: ModelName; - options: NextAdminOptions; + options?: NextAdminOptions; searchParams: URLSearchParams; context: NextAdminContext; appDir?: boolean; @@ -199,7 +199,7 @@ export const optionsFromResource = async ({ ...args }: OptionsFromResourceParams) => { const relationshipField = - args.options.model?.[originResource]?.edit?.fields?.[ + args.options?.model?.[originResource]?.edit?.fields?.[ property as Field // @ts-expect-error ]?.relationshipSearchField; @@ -260,7 +260,7 @@ export const optionsFromResource = async ({ type FetchDataListParams = { prisma: PrismaClient; resource: ModelName; - options: NextAdminOptions; + options?: NextAdminOptions; searchParams: URLSearchParams; }; @@ -317,7 +317,7 @@ export const mapDataList = ({ const { resource, options } = args; const dmmfSchema = getPrismaModelForResource(resource); const data = findRelationInData(fetchData, dmmfSchema?.fields); - const listFields = options.model?.[resource]?.list?.fields ?? {}; + const listFields = options?.model?.[resource]?.list?.fields ?? {}; const originalData = cloneDeep(data); data.forEach((item, index) => { context.row = originalData[index]; diff --git a/packages/next-admin/src/utils/props.ts b/packages/next-admin/src/utils/props.ts index e0ab7d1c..abb68e42 100644 --- a/packages/next-admin/src/utils/props.ts +++ b/packages/next-admin/src/utils/props.ts @@ -1,9 +1,10 @@ -import { Prisma, PrismaClient } from "@prisma/client"; +import { Prisma } from "@prisma/client"; import { cloneDeep } from "lodash"; import { AdminComponentProps, EditOptions, Field, + GetMainLayoutPropsParams, GetPropsFromParamsParams, ListOptions, MainLayoutProps, @@ -43,6 +44,8 @@ export async function getPropsFromParams({ isAppDir = true, locale, getMessages, + basePath, + apiBasePath, }: GetPropsFromParamsParams): Promise< | AdminComponentProps | Omit @@ -50,6 +53,7 @@ export async function getPropsFromParams({ AdminComponentProps, | "pageComponent" | "basePath" + | "apiBasePath" | "isAppDir" | "message" | "resources" @@ -60,13 +64,12 @@ export async function getPropsFromParams({ resource, resources, resourcesTitles, - basePath, customPages, title, sidebar, resourcesIcons, externalLinks, - } = getMainLayoutProps({ options, params, isAppDir }); + } = getMainLayoutProps({ basePath, apiBasePath, options, params, isAppDir }); const resourcesIdProperty = resources!.reduce( (acc, resource) => { @@ -76,10 +79,12 @@ export async function getPropsFromParams({ {} as Record ); - const clientOptions: NextAdminOptions = extractSerializable(options); + const clientOptions: NextAdminOptions | undefined = + extractSerializable(options); let defaultProps: AdminComponentProps = { resources, basePath, + apiBasePath, isAppDir, customPages, resourcesTitles, @@ -172,7 +177,7 @@ export async function getPropsFromParams({ if (fieldTypeDmmf && dmmfSchema) { const relatedResourceOptions = - options.model?.[fieldTypeDmmf as ModelName]?.list; + options?.model?.[fieldTypeDmmf as ModelName]?.list; if ( // @ts-expect-error @@ -259,13 +264,9 @@ export async function getPropsFromParams({ } } -type GetMainLayoutPropsParams = { - options: NextAdminOptions; - params?: string[]; - isAppDir?: boolean; -}; - export const getMainLayoutProps = ({ + basePath, + apiBasePath, options, params, isAppDir = false, @@ -273,16 +274,16 @@ export const getMainLayoutProps = ({ const resources = getResources(options); const resource = getResourceFromParams(params ?? [], resources); - const customPages = Object.keys(options.pages ?? {}).map((path) => ({ - title: options.pages![path as keyof typeof options.pages].title, + const customPages = Object.keys(options?.pages ?? {}).map((path) => ({ + title: options?.pages![path as keyof typeof options.pages].title ?? path, path: path, - icon: options.pages![path as keyof typeof options.pages].icon, + icon: options?.pages![path as keyof typeof options.pages].icon, })); const resourcesTitles = resources.reduce( (acc, resource) => { acc[resource as Prisma.ModelName] = - options.model?.[resource as keyof typeof options.model]?.title ?? + options?.model?.[resource as keyof typeof options.model]?.title ?? resource; return acc; }, @@ -291,7 +292,7 @@ export const getMainLayoutProps = ({ const resourcesIcons = resources.reduce( (acc, resource) => { - if (!options.model?.[resource as keyof typeof options.model]?.icon) + if (!options?.model?.[resource as keyof typeof options.model]?.icon) return acc; acc[resource as Prisma.ModelName] = options.model?.[resource as keyof typeof options.model]?.icon!; @@ -303,14 +304,15 @@ export const getMainLayoutProps = ({ return { resources, resource, - basePath: options.basePath, + basePath, + apiBasePath, customPages, resourcesTitles, isAppDir, - title: options.title ?? "Admin", - sidebar: options.sidebar, + title: options?.title ?? "Admin", + sidebar: options?.sidebar, resourcesIcons, - externalLinks: options.externalLinks, + externalLinks: options?.externalLinks, options: extractSerializable(options), }; }; diff --git a/packages/next-admin/src/utils/server.ts b/packages/next-admin/src/utils/server.ts index 9a1c1386..4a04288b 100644 --- a/packages/next-admin/src/utils/server.ts +++ b/packages/next-admin/src/utils/server.ts @@ -258,7 +258,7 @@ export const transformData = ( data: any, resource: M, editOptions: EditOptions, - options: NextAdminOptions + options?: NextAdminOptions ) => { const modelName = resource; const model = models.find((model) => model.name === modelName); From 76719f220e13f48e3a09cadeac3e7d04ff31706b Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Fri, 21 Jun 2024 18:13:15 +0200 Subject: [PATCH 14/89] Fix build --- apps/example/app/[locale]/admin/custom/page.tsx | 2 ++ apps/example/pageRouterOptions.tsx | 2 -- apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/example/app/[locale]/admin/custom/page.tsx b/apps/example/app/[locale]/admin/custom/page.tsx index 653a7027..95eb212a 100644 --- a/apps/example/app/[locale]/admin/custom/page.tsx +++ b/apps/example/app/[locale]/admin/custom/page.tsx @@ -5,6 +5,8 @@ import { prisma } from "../../../../prisma"; const CustomPage = async () => { const mainLayoutProps = getMainLayoutProps({ + basePath: "/admin", + apiBasePath: "/api/admin", options, isAppDir: true, }); diff --git a/apps/example/pageRouterOptions.tsx b/apps/example/pageRouterOptions.tsx index 7f1abebe..0c6c864b 100644 --- a/apps/example/pageRouterOptions.tsx +++ b/apps/example/pageRouterOptions.tsx @@ -2,8 +2,6 @@ import { NextAdminOptions } from "@premieroctet/next-admin"; import DatePicker from "./components/DatePicker"; export const options: NextAdminOptions = { - basePath: "/pagerouter/admin", - apiBasePath: "/api/pagerouter/admin", title: "⚡️ My Admin", model: { User: { diff --git a/apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts b/apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts index ccd285df..57ba07b7 100644 --- a/apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts +++ b/apps/example/pages/api/pagerouter/admin/[[...nextadmin]].ts @@ -10,7 +10,7 @@ export const config = { }; const { run } = createHandler({ - apiBasePath: "/api/admin", + apiBasePath: "/api/pagerouter/admin", options, prisma, schema: schema, From cf134d6792c89a4b0d345dca7ef57b03365f057a Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Fri, 21 Jun 2024 18:21:27 +0200 Subject: [PATCH 15/89] Fix unit test --- packages/next-admin/src/tests/singleton.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/next-admin/src/tests/singleton.tsx b/packages/next-admin/src/tests/singleton.tsx index 14f82997..ea9661bb 100644 --- a/packages/next-admin/src/tests/singleton.tsx +++ b/packages/next-admin/src/tests/singleton.tsx @@ -670,8 +670,6 @@ export const schema: Schema = { }; export const options: NextAdminOptions = { - basePath: "/admin", - apiBasePath: "/api/admin", model: { User: { toString: (user) => `${user.name} (${user.email})`, From 2aa9b342a45afc86feae81deb83742c3ad8cbfcd Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Fri, 21 Jun 2024 18:34:10 +0200 Subject: [PATCH 16/89] Add redirect on save anyway --- packages/next-admin/src/actions/form.ts | 179 ------------------- packages/next-admin/src/actions/index.ts | 2 - packages/next-admin/src/actions/resources.ts | 59 ------ packages/next-admin/src/components/Form.tsx | 10 +- 4 files changed, 6 insertions(+), 244 deletions(-) delete mode 100644 packages/next-admin/src/actions/form.ts delete mode 100644 packages/next-admin/src/actions/index.ts delete mode 100644 packages/next-admin/src/actions/resources.ts diff --git a/packages/next-admin/src/actions/form.ts b/packages/next-admin/src/actions/form.ts deleted file mode 100644 index d824f859..00000000 --- a/packages/next-admin/src/actions/form.ts +++ /dev/null @@ -1,179 +0,0 @@ -"use server"; -import { - PrismaClientKnownRequestError, - PrismaClientValidationError, -} from "@prisma/client/runtime/library"; -import { - ActionFullParams, - EditFieldsOptions, - Permission, - SubmitFormResult, -} from "../types"; -import { - formattedFormData, - getFormValuesFromFormData, - getModelIdProperty, - getPrismaModelForResource, - getResourceFromParams, - getResourceIdFromParam, - getResources, - parseFormData, -} from "../utils/server"; -import { validate } from "../utils/validator"; - -export const submitForm = async ( - { options, params, schema, prisma }: ActionFullParams, - formData: FormData -): Promise => { - if (!params) { - return; - } - - const resources = getResources(options); - const resource = getResourceFromParams(params, resources); - - if (!resource) { - return; - } - - const resourceIdField = getModelIdProperty(resource); - - const resourceId = getResourceIdFromParam(params[1], resource); - - const { - __admin_redirect: redirect, - __admin_action: action, - ...formValues - } = await getFormValuesFromFormData(formData); - - const dmmfSchema = getPrismaModelForResource(resource); - const parsedFormData = parseFormData(formValues, dmmfSchema?.fields!); - - try { - if (action === "delete") { - if (resourceId !== undefined) { - if ( - options?.model?.[resource]?.permissions && - !options?.model?.[resource]?.permissions?.includes(Permission.DELETE) - ) { - return { - error: "Unable to delete items of this model", - }; - } - - // @ts-expect-error - await prisma[resource].delete({ - where: { - [resourceIdField]: resourceId, - }, - }); - } - return { deleted: true }; - } - - // Update - let data; - - const fields = options.model?.[resource]?.edit?.fields as EditFieldsOptions< - typeof resource - >; - - // Validate - validate(parsedFormData, fields); - - const { formattedData, complementaryFormattedData, errors } = - await formattedFormData( - formValues, - dmmfSchema?.fields!, - schema, - resource, - resourceId, - fields - ); - - if (errors.length) { - return { - error: - options.model?.[resource]?.edit?.submissionErrorMessage ?? - "Submission error", - validation: errors.map((error) => ({ - property: error.field, - message: error.message, - })), - }; - } - - if (resourceId !== undefined) { - if ( - options?.model?.[resource]?.permissions && - !options?.model?.[resource]?.permissions?.includes(Permission.EDIT) - ) { - return { - error: "Unable to update items of this model", - }; - } - - // @ts-expect-error - data = await prisma[resource].update({ - where: { - [resourceIdField]: resourceId, - }, - data: formattedData, - }); - - return { updated: true, redirect: redirect === "list" }; - } - - // Create - if ( - options?.model?.[resource]?.permissions && - !options?.model?.[resource]?.permissions?.includes(Permission.CREATE) - ) { - return { - error: "Unable to create items of this model", - }; - } - - // @ts-expect-error - data = await prisma[resource].create({ - data: formattedData, - }); - - // @ts-expect-error - await prisma[resource].update({ - where: { - [resourceIdField]: data[resourceIdField], - }, - data: complementaryFormattedData, - }); - - return { - created: true, - createdId: data[resourceIdField], - redirect: redirect === "list", - }; - } catch (error: any) { - if ( - error.constructor.name === PrismaClientValidationError.name || - error.constructor.name === PrismaClientKnownRequestError.name || - error.name === "ValidationError" - ) { - let data = parsedFormData; - - // TODO This could be improved by merging form values but it's breaking stuff - if (error.name === "ValidationError") { - error.errors.map((error: any) => { - // @ts-expect-error - data[error.property] = formData[error.property]; - }); - } - - return { - error: error.message, - validation: error.errors, - }; - } - - throw error; - } -}; diff --git a/packages/next-admin/src/actions/index.ts b/packages/next-admin/src/actions/index.ts deleted file mode 100644 index e611836c..00000000 --- a/packages/next-admin/src/actions/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./form"; -export * from "./resources"; diff --git a/packages/next-admin/src/actions/resources.ts b/packages/next-admin/src/actions/resources.ts deleted file mode 100644 index a33124b3..00000000 --- a/packages/next-admin/src/actions/resources.ts +++ /dev/null @@ -1,59 +0,0 @@ -"use server"; - -import { PrismaClient } from "@prisma/client"; -import { ActionFullParams, ModelName } from "../types"; -import { optionsFromResource } from "../utils/prisma"; -import { getModelIdProperty } from "../utils/server"; -import { uncapitalize } from "../utils/tools"; - -export const deleteResourceItems = async ( - prisma: PrismaClient, - model: M, - ids: string[] | number[] -) => { - const modelIdProperty = getModelIdProperty(model); - // @ts-expect-error - await prisma[uncapitalize(model)].deleteMany({ - where: { - [modelIdProperty]: { in: ids }, - }, - }); -}; - -export type SearchPaginatedResourceParams = { - originModel: string; - property: string; - model: string; - query: string; - page?: number; - perPage?: number; -}; - -export const searchPaginatedResource = async ( - { options, prisma }: ActionFullParams, - { - originModel, - property, - model, - query, - page = 1, - perPage = 25, - }: SearchPaginatedResourceParams -) => { - const data = await optionsFromResource({ - prisma, - originResource: originModel as ModelName, - property: property, - resource: model as ModelName, - options, - context: {}, - searchParams: new URLSearchParams({ - search: query, - page: page.toString(), - itemsPerPage: perPage.toString(), - }), - appDir: true, - }); - - return data; -}; diff --git a/packages/next-admin/src/components/Form.tsx b/packages/next-admin/src/components/Form.tsx index 1621e4c7..6e23ec70 100644 --- a/packages/next-admin/src/components/Form.tsx +++ b/packages/next-admin/src/components/Form.tsx @@ -34,7 +34,9 @@ import { twMerge } from "tailwind-merge"; import { useConfig } from "../context/ConfigContext"; import { FormContext, FormProvider } from "../context/FormContext"; import { useI18n } from "../context/I18nContext"; +import { MessageProvider, useMessage } from "../context/MessageContext"; import { PropertyValidationError } from "../exceptions/ValidationError"; +import { useDeleteAction } from "../hooks/useDeleteAction"; import { useRouterInternal } from "../hooks/useRouterInternal"; import { AdminComponentProps, @@ -67,8 +69,6 @@ import { TooltipRoot, TooltipTrigger, } from "./radix/Tooltip"; -import { useDeleteAction } from "../hooks/useDeleteAction"; -import { MessageProvider, useMessage } from "../context/MessageContext"; const RichTextField = dynamic(() => import("./inputs/RichText/RichTextField"), { ssr: false, @@ -271,7 +271,9 @@ const Form = ({ } if (result?.created) { - const pathname = `${basePath}/${slugify(resource)}/${result.createdId}`; + const pathname = result?.redirect + ? `${basePath}/${slugify(resource)}` + : `${basePath}/${slugify(resource)}/${result.createdId}`; return router.replace({ pathname, query: { @@ -547,7 +549,7 @@ const Form = ({ return (

-
+
{!!actions && actions.length > 0 && !!id && ( From 8f72ecc14ef96adb93eafe200fdc38ef8341495d Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Mon, 24 Jun 2024 11:46:53 +0200 Subject: [PATCH 17/89] Remove unused code --- apps/example/actions/nextadmin.ts | 41 ------------------- apps/example/actions/posts.ts | 30 -------------- .../app/[locale]/admin/custom/page.tsx | 2 +- .../pages/pagerouter/admin/custom/index.tsx | 2 +- packages/next-admin/src/appRouter.ts | 9 ++-- .../next-admin/src/{index.tsx => index.ts} | 5 ++- packages/next-admin/src/mainLayout.tsx | 1 - packages/next-admin/src/pageRouter.ts | 9 +++- packages/next-admin/src/utils/actions.ts | 16 -------- packages/next-admin/src/utils/prisma.ts | 33 ++------------- packages/next-admin/src/utils/props.ts | 2 +- packages/next-admin/src/utils/server.test.ts | 34 --------------- packages/next-admin/src/utils/server.ts | 41 ++++--------------- packages/next-admin/tsconfig.json | 2 +- 14 files changed, 31 insertions(+), 196 deletions(-) delete mode 100644 apps/example/actions/nextadmin.ts delete mode 100644 apps/example/actions/posts.ts rename packages/next-admin/src/{index.tsx => index.ts} (99%) delete mode 100644 packages/next-admin/src/mainLayout.tsx delete mode 100644 packages/next-admin/src/utils/actions.ts diff --git a/apps/example/actions/nextadmin.ts b/apps/example/actions/nextadmin.ts deleted file mode 100644 index 06d940f0..00000000 --- a/apps/example/actions/nextadmin.ts +++ /dev/null @@ -1,41 +0,0 @@ -"use server"; -import { ActionParams, ModelName } from "@premieroctet/next-admin"; -import { - SearchPaginatedResourceParams, - deleteResourceItems, - searchPaginatedResource, - submitForm, -} from "@premieroctet/next-admin/dist/actions"; -import { options } from "../options"; -import { prisma } from "../prisma"; - -const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)); - -export const submitFormAction = async ( - params: ActionParams, - formData: FormData -) => { - return submitForm({ ...params, options, prisma }, formData); -}; - -export const submitEmail = async ( - model: ModelName, - ids: number[] | string[] -) => { - console.log("Sending email to " + ids.length + " users"); - await delay(1000); -}; - -export const deleteItem = async ( - model: ModelName, - ids: string[] | number[] -) => { - return deleteResourceItems(prisma, model, ids); -}; - -export const searchResource = async ( - actionParams: ActionParams, - params: SearchPaginatedResourceParams -) => { - return searchPaginatedResource({ ...actionParams, options, prisma }, params); -}; diff --git a/apps/example/actions/posts.ts b/apps/example/actions/posts.ts deleted file mode 100644 index caab0a6d..00000000 --- a/apps/example/actions/posts.ts +++ /dev/null @@ -1,30 +0,0 @@ -"use server"; - -import { redirect } from "next/navigation"; -import { prisma } from "../prisma"; - -export const createRandomPost = async () => { - const firstUser = await prisma.user.findFirst(); - const post = await prisma.post.create({ - data: { - title: "Random Post", - author: { - connect: { - id: firstUser?.id, - }, - }, - }, - }); - - const params = new URLSearchParams(); - - params.set( - "message", - JSON.stringify({ - type: "success", - content: "Random post created", - }) - ); - - redirect(`/admin/post/${post.id}?${params.toString()}`); -}; diff --git a/apps/example/app/[locale]/admin/custom/page.tsx b/apps/example/app/[locale]/admin/custom/page.tsx index 95eb212a..990df770 100644 --- a/apps/example/app/[locale]/admin/custom/page.tsx +++ b/apps/example/app/[locale]/admin/custom/page.tsx @@ -1,5 +1,5 @@ import { MainLayout } from "@premieroctet/next-admin"; -import { getMainLayoutProps } from "@premieroctet/next-admin/dist/mainLayout"; +import { getMainLayoutProps } from "@premieroctet/next-admin/dist/appRouter"; import { options } from "../../../../options"; import { prisma } from "../../../../prisma"; diff --git a/apps/example/pages/pagerouter/admin/custom/index.tsx b/apps/example/pages/pagerouter/admin/custom/index.tsx index 4c8d66ef..f3d78435 100644 --- a/apps/example/pages/pagerouter/admin/custom/index.tsx +++ b/apps/example/pages/pagerouter/admin/custom/index.tsx @@ -1,5 +1,5 @@ import { MainLayout, MainLayoutProps } from "@premieroctet/next-admin"; -import { getMainLayoutProps } from "@premieroctet/next-admin/dist/mainLayout"; +import { getMainLayoutProps } from "@premieroctet/next-admin/dist/pageRouter"; import { GetServerSideProps } from "next"; import { options } from "../../../../options"; import { prisma } from "../../../../prisma"; diff --git a/packages/next-admin/src/appRouter.ts b/packages/next-admin/src/appRouter.ts index 4521c356..c78fb257 100644 --- a/packages/next-admin/src/appRouter.ts +++ b/packages/next-admin/src/appRouter.ts @@ -1,6 +1,9 @@ -"use server"; import { GetPropsFromParamsParams } from "./types"; import { getPropsFromParams as _getPropsFromParams } from "./utils/props"; -export const getNextAdminProps = (params: GetPropsFromParamsParams) => - _getPropsFromParams({ ...params, isAppDir: true }); +export const getNextAdminProps = async (params: GetPropsFromParamsParams) => { + "use server"; + return _getPropsFromParams({ ...params, isAppDir: true }); +}; + +export { getMainLayoutProps } from "./utils/props"; \ No newline at end of file diff --git a/packages/next-admin/src/index.tsx b/packages/next-admin/src/index.ts similarity index 99% rename from packages/next-admin/src/index.tsx rename to packages/next-admin/src/index.ts index 1c37010e..6b424880 100644 --- a/packages/next-admin/src/index.tsx +++ b/packages/next-admin/src/index.ts @@ -1,3 +1,4 @@ -export * from "./types"; -export * from "./components/NextAdmin"; export * from "./components/MainLayout"; +export * from "./components/NextAdmin"; +export * from "./types"; + diff --git a/packages/next-admin/src/mainLayout.tsx b/packages/next-admin/src/mainLayout.tsx deleted file mode 100644 index 44333506..00000000 --- a/packages/next-admin/src/mainLayout.tsx +++ /dev/null @@ -1 +0,0 @@ -export { getMainLayoutProps } from "./utils/props"; diff --git a/packages/next-admin/src/pageRouter.ts b/packages/next-admin/src/pageRouter.ts index 86eea58f..2c591064 100644 --- a/packages/next-admin/src/pageRouter.ts +++ b/packages/next-admin/src/pageRouter.ts @@ -1,6 +1,9 @@ import { IncomingMessage } from "node:http"; -import { GetPropsFromParamsParams } from "./types"; -import { getPropsFromParams } from "./utils/props"; +import { GetMainLayoutPropsParams, GetPropsFromParamsParams } from "./types"; +import { + getMainLayoutProps as _getMainLayoutProps, + getPropsFromParams, +} from "./utils/props"; import { formatSearchFields, getParamsFromUrl } from "./utils/server"; // Router @@ -31,3 +34,5 @@ export const getNextAdminProps = async ({ return { props }; }; +export const getMainLayoutProps = (args: GetMainLayoutPropsParams) => + _getMainLayoutProps({ ...args, isAppDir: false }); diff --git a/packages/next-admin/src/utils/actions.ts b/packages/next-admin/src/utils/actions.ts deleted file mode 100644 index 81fd04a9..00000000 --- a/packages/next-admin/src/utils/actions.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ActionParams } from "../types"; - -/** - * Following https://nextjs.org/docs/app/api-reference/functions/server-actions#binding-arguments - * We need the params and schema options to be there when the action is called. - * Other params (prisma, options) will be added by the app's action implementation. - */ -export const createBoundServerAction = < - Args extends any[], - Params = ActionParams, ->( - actionParams: Params, - action: (params: Params, ...args: Args) => Promise -) => { - return action.bind(null, actionParams); -}; diff --git a/packages/next-admin/src/utils/prisma.ts b/packages/next-admin/src/utils/prisma.ts index d8d5e53f..982038af 100644 --- a/packages/next-admin/src/utils/prisma.ts +++ b/packages/next-admin/src/utils/prisma.ts @@ -24,7 +24,7 @@ import { } from "./server"; import { capitalize, isScalar, uncapitalize } from "./tools"; -export const createWherePredicate = ( +const createWherePredicate = ( fieldsFiltered?: readonly Prisma.DMMF.Field[], search?: string, otherFilters?: Filter[] @@ -76,7 +76,7 @@ export const createWherePredicate = ( return { AND: [...externalFilters, searchFilter] }; }; -export const getFieldsFiltered = ( +const getFieldsFiltered = ( resource: M, options?: NextAdminOptions ): readonly Prisma.DMMF.Field[] => { @@ -95,7 +95,7 @@ export const getFieldsFiltered = ( return fieldsFiltered as readonly Prisma.DMMF.Field[]; }; -export const preparePrismaListRequest = ( +const preparePrismaListRequest = ( resource: M, searchParams: any, options?: NextAdminOptions, @@ -264,7 +264,7 @@ type FetchDataListParams = { searchParams: URLSearchParams; }; -export const fetchDataList = async ( +const fetchDataList = async ( { prisma, resource, options, searchParams }: FetchDataListParams, skipFilters: boolean = false ) => { @@ -436,28 +436,3 @@ export const selectPayloadForModel = ( return selectedFields; }; - -export const includeOrderByPayloadForModel = ( - resource: M, - options: EditOptions -) => { - const model = getPrismaModelForResource(resource); - - let orderedFields = model?.fields.reduce( - (acc, field) => { - // @ts-expect-error - if (options.fields?.[field.name as Field]?.orderField) { - acc[field.name] = { - orderBy: { - // @ts-expect-error - [options.fields[field.name as Field].orderField]: "asc", - }, - }; - } - return acc; - }, - {} as Record - ); - - return orderedFields; -}; diff --git a/packages/next-admin/src/utils/props.ts b/packages/next-admin/src/utils/props.ts index abb68e42..7dcb0d79 100644 --- a/packages/next-admin/src/utils/props.ts +++ b/packages/next-admin/src/utils/props.ts @@ -269,7 +269,7 @@ export const getMainLayoutProps = ({ apiBasePath, options, params, - isAppDir = false, + isAppDir = true, }: GetMainLayoutPropsParams): MainLayoutProps => { const resources = getResources(options); const resource = getResourceFromParams(params ?? [], resources); diff --git a/packages/next-admin/src/utils/server.test.ts b/packages/next-admin/src/utils/server.test.ts index ac8aceda..b2defc6c 100644 --- a/packages/next-admin/src/utils/server.test.ts +++ b/packages/next-admin/src/utils/server.test.ts @@ -1,26 +1,10 @@ import { getParamsFromUrl, getResourceFromParams, - getResourceFromUrl, getResourceIdFromParam, - getResourceIdFromUrl, } from "./server"; describe("Server utils", () => { - describe("getResourceFromUrl", () => { - it("should return a resource with /admin/User", () => { - expect(getResourceFromUrl("/admin/User", ["User"])).toEqual("User"); - }); - - it("should return a resource with /admin/User/1", () => { - expect(getResourceFromUrl("/admin/User/1", ["User"])).toEqual("User"); - }); - - it("should not return a resource with /admin/Post", () => { - expect(getResourceFromUrl("/admin/Post", ["User"])).toEqual(undefined); - }); - }); - describe("getResourceFromParams", () => { it("should return a resource with /admin/User", () => { expect(getResourceFromParams(["User"], ["User"])).toEqual("User"); @@ -39,24 +23,6 @@ describe("Server utils", () => { }); }); - describe("getResourceIdFromUrl", () => { - it("should get the id from /admin/User/1", () => { - expect(getResourceIdFromUrl("/admin/User/1", "User")).toEqual(1); - }); - - it("should not return an id from /admin/User/new", () => { - expect(getResourceIdFromUrl("/admin/User/new", "User")).toEqual( - undefined - ); - }); - - it("should not return an id from /admin/Dummy/--__", () => { - expect(getResourceIdFromUrl("/admin/Dummy/--__", "User")).toEqual( - undefined - ); - }); - }); - describe("getResourceIdFromParam", () => { it("should get the id from /admin/User/1", () => { expect(getResourceIdFromParam("1", "User")).toEqual(1); diff --git a/packages/next-admin/src/utils/server.ts b/packages/next-admin/src/utils/server.ts index 4a04288b..31f4c5cf 100644 --- a/packages/next-admin/src/utils/server.ts +++ b/packages/next-admin/src/utils/server.ts @@ -24,7 +24,7 @@ export const models: readonly Prisma.DMMF.Model[] = Prisma.dmmf.datamodel export const enums = Prisma.dmmf.datamodel.enums; export const resources = models.map((model) => model.name as ModelName); -export const getEnumValues = (enumName: string) => { +const getEnumValues = (enumName: string) => { const enumValues = enums.find((en) => en.name === enumName); return enumValues?.values; }; @@ -50,7 +50,7 @@ export const getModelIdProperty = (model: ModelName) => { return idField?.name ?? "id"; }; -export const getDeepRelationModel = ( +const getDeepRelationModel = ( model: M, property: Field ): Prisma.DMMF.Field | undefined => { @@ -121,7 +121,7 @@ export const getToStringForModel = ( * * @returns schema */ -export const orderSchema = +const orderSchema = (resource: ModelName, options?: NextAdminOptions) => (schema: Schema) => { const modelName = resource; const model = models.find((model) => model.name === modelName); @@ -159,7 +159,7 @@ export const orderSchema = * * @returns schema */ -export const fillRelationInSchema = +const fillRelationInSchema = (resource: ModelName, options?: NextAdminOptions) => async (schema: Schema) => { const modelName = resource; @@ -852,7 +852,7 @@ export const transformSchema = ( orderSchema(resource, options) ); -export const fillDescriptionInSchema = ( +const fillDescriptionInSchema = ( resource: M, editOptions: EditOptions ) => { @@ -875,7 +875,7 @@ export const fillDescriptionInSchema = ( }; }; -export const changeFormatInSchema = +const changeFormatInSchema = (resource: M, editOptions: EditOptions) => (schema: Schema) => { const modelName = resource; @@ -907,7 +907,7 @@ export const changeFormatInSchema = return schema; }; -export const removeHiddenProperties = +const removeHiddenProperties = (resource: M, editOptions: EditOptions) => (schema: Schema) => { if (!editOptions?.display) return schema; @@ -920,13 +920,6 @@ export const removeHiddenProperties = return schema; }; -export const getResourceFromUrl = ( - url: string, - resources: Prisma.ModelName[] -): ModelName | undefined => { - return resources.find((r) => url.includes(`/${r}`)); -}; - export const getResourceFromParams = ( params: string[], resources: Prisma.ModelName[] @@ -961,26 +954,6 @@ export const getParamsFromUrl = (url: string, basePath: string) => { return urlWithoutParams.split("/").filter(Boolean); }; -export const getResourceIdFromUrl = ( - url: string, - resource: ModelName -): string | number | undefined => { - const matching = url.match(`/${resource}/([0-9a-z-]+)`); - - if (!matching) return undefined; - if (matching[1] === "new") return undefined; - - const model = models.find((model) => model.name === resource); - - const idType = model?.fields.find((field) => field.name === "id")?.type; - - if (idType === "Int") { - return Number(matching[1]); - } - - return matching ? matching[1] : undefined; -}; - export const getResourceIdFromParam = (param: string, resource: ModelName) => { if (param === "new") { return undefined; diff --git a/packages/next-admin/tsconfig.json b/packages/next-admin/tsconfig.json index eba795c4..003214d6 100644 --- a/packages/next-admin/tsconfig.json +++ b/packages/next-admin/tsconfig.json @@ -2,7 +2,7 @@ "extends": "tsconfig/react-library.json", "include": [ "./src/pageRouter.ts", - "./src/index.tsx", + "src/index.ts", "./src/appRouter.ts", "./src/actions/index.ts", "./src/mainLayout.tsx", From bc0adf1376c97937fb74385649f90dff887aab24 Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Mon, 24 Jun 2024 11:58:50 +0200 Subject: [PATCH 18/89] Fix unit test --- packages/next-admin/src/utils/server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/next-admin/src/utils/server.ts b/packages/next-admin/src/utils/server.ts index 31f4c5cf..c54b2f08 100644 --- a/packages/next-admin/src/utils/server.ts +++ b/packages/next-admin/src/utils/server.ts @@ -159,7 +159,7 @@ const orderSchema = * * @returns schema */ -const fillRelationInSchema = +export const fillRelationInSchema = (resource: ModelName, options?: NextAdminOptions) => async (schema: Schema) => { const modelName = resource; @@ -875,7 +875,7 @@ const fillDescriptionInSchema = ( }; }; -const changeFormatInSchema = +export const changeFormatInSchema = (resource: M, editOptions: EditOptions) => (schema: Schema) => { const modelName = resource; @@ -907,7 +907,7 @@ const changeFormatInSchema = return schema; }; -const removeHiddenProperties = +export const removeHiddenProperties = (resource: M, editOptions: EditOptions) => (schema: Schema) => { if (!editOptions?.display) return schema; From 38a505a96728ba5387d76d4d79216dd81a35ec59 Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Mon, 24 Jun 2024 14:38:32 +0200 Subject: [PATCH 19/89] Change README and changeset --- .changeset/strong-cobras-look.md | 125 ++++++++++++++- apps/docs/pages/docs/api-docs.mdx | 123 +-------------- apps/docs/pages/docs/code-snippets.mdx | 120 ++++++++++++++ apps/docs/pages/docs/getting-started.mdx | 2 +- packages/next-admin/README.md | 148 ++++++++---------- .../next-admin/src/components/Message.tsx | 8 +- packages/next-admin/tsconfig.json | 1 - 7 files changed, 320 insertions(+), 207 deletions(-) diff --git a/.changeset/strong-cobras-look.md b/.changeset/strong-cobras-look.md index a9851123..54b799e4 100644 --- a/.changeset/strong-cobras-look.md +++ b/.changeset/strong-cobras-look.md @@ -2,4 +2,127 @@ "@premieroctet/next-admin": major --- -API router implementation +## Major Changes + +- **Breaking Change**: + + - New implementation of `NextAdmin`. Usage of `API route` instead of `server actions`. + - Configuration of `page.tsx` and `route.ts` files in the `app/admin/[[...nextadmin]]` and `app/api/[[...nextadmin]]` folders respectively. + - `createHandler` function now available in `appHandler` and `pageHandler` modules to configure the API route. + - `getNextAdminProps` function now available in `appRouter` and `pageRouter` modules to configure the page route. + +## Migration + +### API Route `[[...nextadmin]]` + +Create a dynamic route `[[...nextadmin]]` to handle all the API routes. + +
+App router + +```tsx +// app/api/admin/[[...nextadmin]]/route.ts +import { prisma } from "@/prisma"; +import { createHandler } from "@premieroctet/next-admin/dist/appHandler"; + +const { run } = createHandler({ + apiBasePath: "/api/admin", + prisma, + /*options*/ +}); + +export { run as DELETE, run as GET, run as POST }; +``` + +
+ +
+Page router + +```ts copy + // pages/api/admin/[[...nextadmin]].ts + import { prisma } from "@/prisma"; + import { createApiRouter } from "@premieroctet/next-admin/dist/pageHandler"; + import schema from "@/prisma/json-schema/json-schema.json"; + + export const config = { + api: { + bodyParser: false, + }, + }; + + const { run } = createHandler({ + apiBasePath: "/api/admin", + prisma, + schema: schema, + /*options*/, + }); + + export default run; +``` + +
+ +### Change `getPropsFromParams` to `getNextAdminProps` + +
+App router + +Replace the `getPropsFromParams` function with the `getNextAdminProps` function in the `page.tsx` file. + +```tsx +// app/admin/[[...nextadmin]]/page.tsx +import { NextAdmin, PageProps } from "@premieroctet/next-admin"; +import { getNextAdminProps } from "@premieroctet/next-admin/dist/appRouter"; +import { prisma } from "@/prisma"; + +export default async function AdminPage({ params, searchParams }: PageProps) { + const props = await getNextAdminProps({ + params: params.nextadmin, + searchParams, + basePath: "/admin", + apiBasePath: "/api/admin", + prisma, + /*options*/ + }); + + return ; +} +``` + +
+ +
+Page router + +Do not use `nextAdminRouter` anymore. Replace it with the `getNextAdminProps` function in the `[[...nextadmin]].ts` file for `getServerSideProps`. + +```tsx copy +// pages/admin/[[...nextadmin]].tsx +import { AdminComponentProps, NextAdmin } from "@premieroctet/next-admin"; + +import { getNextAdminProps } from "@premieroctet/next-admin/dist/pageRouter"; +import { GetServerSideProps } from "next"; +import { prisma } from " @/prisma"; +import schema from "@/prisma/json-schema/json-schema.json"; +import "@/styles.css"; + +export default function Admin(props: AdminComponentProps) { + return ( + + ); +} + +export const getServerSideProps: GetServerSideProps = async ({ req }) => + await getNextAdminProps({ + basePath: "/pagerouter/admin", + apiBasePath: "/api/pagerouter/admin", + prisma, + schema, + /*options*/ + req, + }); +``` diff --git a/apps/docs/pages/docs/api-docs.mdx b/apps/docs/pages/docs/api-docs.mdx index f4af86e3..77a7cb1a 100644 --- a/apps/docs/pages/docs/api-docs.mdx +++ b/apps/docs/pages/docs/api-docs.mdx @@ -14,8 +14,8 @@ import { Tabs } from "nextra/components"; - `params`: the array of route params retrieved from the [optional catch-all segment](https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes#optional-catch-all-segments) - `searchParams`: the query params [retrieved from the page](https://nextjs.org/docs/app/api-reference/file-conventions/page#searchparams-optional) - - `basePath`: the base path of your admin. It is used to build the admin URL. It is optional and defaults to `/admin` - - `apiBasePath`: the base path of your admin API. It is used to build the admin API URL. It is optional and defaults to `/api/admin` + - `basePath`: the base path of your admin. It is used to build the admin URL. It is mandatory. + - `apiBasePath`: the base path of your admin API. It is used to build the admin API URL. It is mandatory. - `schema`: the json schema generated by the `prisma generate` command - `prisma`: your Prisma client instance - `getMessages`: a function with no parameters that returns translation messages. It is used to translate the default messages of the library. See [i18n](/docs/i18n) for more details. @@ -42,125 +42,6 @@ import { Tabs } from "nextra/components"; -## Authentication - - - - The library does not provide an authentication system. If you want to add your own, you can do so by adding a role check in the page: - - > The following example uses [next-auth](https://next-auth.js.org/) to handle authentication - - ```ts - // app/api/admin/[[...nextadmin]]/route.ts - const { run } = createHandler({ - options, - prisma, - apiBasePath: "/api/admin", - schema, - onRequest: (req) => { - const session = await getServerSession(authOptions); - const isAdmin = session?.user?.role === "SUPERADMIN"; - - if (!isAdmin) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - } - }); - - export { run as POST, run as GET, run as DELETE }; - ``` - - ```tsx - // app/admin/[[...nextadmin]]/page.tsx - export default async function AdminPage({ - params, - searchParams, - }: { - params: { [key: string]: string[] }; - searchParams: { [key: string]: string | string[] | undefined } | undefined; - }) { - const session = await getServerSession(authOptions); - const isAdmin = session?.user?.role === "SUPERADMIN"; // your role check - - if (!isAdmin) { - redirect('/', { permanent: false }) - } - - const props = await getNextAdminProps({ - params: params.nextadmin, - searchParams, - basePath: "/admin", - apiBasePath: "/api/admin", - prisma, - schema, - }); - - return ; - } - ``` - - - - The library does not provide an authentication system. If you want to add your own, you can do so by adding a role check to the `getServerSideProps` function: - - > The following example uses [next-auth](https://next-auth.js.org/) to handle authentication - - ```ts - export const config = { - api: { - bodyParser: false, - }, - }; - - const { run } = createHandler({ - prisma, - options, - apiBasePath: "/api/admin", - schema: schema, - onRequest: (req, res, next) => { - const session = await getServerSession(req, res, authOptions); - const isAdmin = session?.user?.role === "SUPERADMIN"; - - if (!isAdmin) { - return res.status(403).json({ error: 'Forbidden' }) - } - - return next() - } - }); - - export default run; - ``` - - ```ts - // pages/api/admin/[[...nextadmin]].ts - export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { - const session = await getServerSession(req, res, authOptions); - const isAdmin = session?.user?.role === "SUPERADMIN"; // your role check - - if (!isAdmin) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } - - return await getNextAdminProps({ - basePath: "/pagerouter/admin", - apiBasePath: "/api/pagerouter/admin", - prisma, - schema, - options, - req, - }); - }; - ``` - - - - ## `` component `` is a React component that contains the entire UI of Next Admin. It can take several props from `getNextAdminProps` function, but also some additional props: diff --git a/apps/docs/pages/docs/code-snippets.mdx b/apps/docs/pages/docs/code-snippets.mdx index 45610018..5051d303 100644 --- a/apps/docs/pages/docs/code-snippets.mdx +++ b/apps/docs/pages/docs/code-snippets.mdx @@ -4,6 +4,126 @@ This page contains code snippets that you can use in your projects. These are no Some of the snippets are implemented in the example project. You can check them out in the [example project](https://next-admin-po.vercel.app) or in the [source code](https://github.com/premieroctet/next-admin/tree/main/apps/example). +## Authentication + + + + The library does not provide an authentication system. If you want to add your own, you can do so by adding a role check in the page: + + > The following example uses [next-auth](https://next-auth.js.org/) to handle authentication + + ```ts + // app/api/admin/[[...nextadmin]]/route.ts + const { run } = createHandler({ + options, + prisma, + apiBasePath: "/api/admin", + schema, + onRequest: (req) => { + const session = await getServerSession(authOptions); + const isAdmin = session?.user?.role === "SUPERADMIN"; + + if (!isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + } + }); + + export { run as POST, run as GET, run as DELETE }; + ``` + + ```tsx + // app/admin/[[...nextadmin]]/page.tsx + export default async function AdminPage({ + params, + searchParams, + }: { + params: { [key: string]: string[] }; + searchParams: { [key: string]: string | string[] | undefined } | undefined; + }) { + const session = await getServerSession(authOptions); + const isAdmin = session?.user?.role === "SUPERADMIN"; // your role check + + if (!isAdmin) { + redirect('/', { permanent: false }) + } + + const props = await getNextAdminProps({ + params: params.nextadmin, + searchParams, + basePath: "/admin", + apiBasePath: "/api/admin", + prisma, + schema, + }); + + return ; + } + ``` + + + + The library does not provide an authentication system. If you want to add your own, you can do so by adding a role check to the `getServerSideProps` function: + + > The following example uses [next-auth](https://next-auth.js.org/) to handle authentication + + ```ts + export const config = { + api: { + bodyParser: false, + }, + }; + + const { run } = createHandler({ + prisma, + options, + apiBasePath: "/api/admin", + schema: schema, + onRequest: (req, res, next) => { + const session = await getServerSession(req, res, authOptions); + const isAdmin = session?.user?.role === "SUPERADMIN"; + + if (!isAdmin) { + return res.status(403).json({ error: 'Forbidden' }) + } + + return next() + } + }); + + export default run; + ``` + + ```ts + // pages/api/admin/[[...nextadmin]].ts + export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { + const session = await getServerSession(req, res, authOptions); + const isAdmin = session?.user?.role === "SUPERADMIN"; // your role check + + if (!isAdmin) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + + return await getNextAdminProps({ + basePath: "/pagerouter/admin", + apiBasePath: "/api/pagerouter/admin", + prisma, + schema, + options, + req, + }); + }; + ``` + + + + + ## Export data By using [exports](/docs/api-docs#exports-property) options, you can export data. Next Admin only implements the CSV export button, it's actually a link pointing to a provided url. diff --git a/apps/docs/pages/docs/getting-started.mdx b/apps/docs/pages/docs/getting-started.mdx index 915c5329..7c8080c3 100644 --- a/apps/docs/pages/docs/getting-started.mdx +++ b/apps/docs/pages/docs/getting-started.mdx @@ -71,7 +71,7 @@ Next-Admin uses a dynamic route `[[...nextadmin]]` to handle all the admin route - Create file : `app/api/admin/[[...nextadmin]]/route.ts` + Create file : `app/api/admin/[[...nextadmin]]/page.ts` ```tsx copy // app/admin/[[...nextadmin]]/page.tsx diff --git a/packages/next-admin/README.md b/packages/next-admin/README.md index 508efad2..ec97baa7 100644 --- a/packages/next-admin/README.md +++ b/packages/next-admin/README.md @@ -27,7 +27,7 @@ To install the library, run the following command: -```shell +```shell copy yarn add @premieroctet/next-admin prisma-json-schema-generator ``` @@ -39,124 +39,108 @@ For detailed documentation, please refer to the [documentation](https://next-adm To use the library in your Next.js application, follow these steps: -1. Create an admin route in your Next.js application. -2. Add the file `[[...nextadmin]].js` to the `pages/admin` directory. -3. Export the `NextAdmin` component from the file. -4. Use `getServerSideProps` to pass the `props` from the `nextAdminRouter` to the `NextAdmin` component. -5. Add the file `[[...nextadmin]].js` to the `pages/api/admin` directory. -6. Export `run` retrieved from the `createApiRouter` function +1. Add tailwind preset to your `tailwind.config.js` file - [more details](http://next-admin-docs.vercel.app/docs/getting-started#tailwindcss) +2. Add json schema generator to your Prisma schema file - [more details](http://next-admin-docs.vercel.app/docs/getting-started#prisma) +3. Generate the schema with `yarn run prisma generate` +4. Create a catch-all segment page `page.tsx` in the `app/admin/[[...nextadmin]]` folder - [more details](http://next-admin-docs.vercel.app/docs/getting-started#page-nextadmin) +5. Create an catch-all API route `route.ts` in the `app/api/[[...nextadmin]]` folder - [more details](http://next-admin-docs.vercel.app/docs/getting-started#api-route-nextadmin) -Bonus: Customize the admin dashboard by passing the `NextAdminOptions` options to the router and customize the admin dashboard by passing `dashboard` props to `NextAdmin` component. (More details in the [documentation](https://next-admin-docs.vercel.app/)) +Bonus: Customize the admin dashboard by passing the `NextAdminOptions` options to the router and customize the admin dashboard by passing `dashboard` props to `NextAdmin` component. (More details in the [documentation](http://next-admin-docs.vercel.app/docs/getting-started#next-admin-options---optional)) ## Example Here's a basic example of how to use the library: -#### App router +#### TailwindCSS -Set Next Admin server actions into your app: +Add the following configuration to your `tailwind.config.js` file -```ts -// app/api/[[...nextadmin]]/route.ts -import { createAppHandler } from "@premieroctet/next-admin/dist/handler"; -import schema from "@/prisma/json-schema/json-schema.json"; -import { prisma } from "@/prisma"; -import { options } from "@/options"; +```typescript copy +module.exports = { + content: [ + "./node_modules/@premieroctet/next-admin/dist/**/*.{js,ts,jsx,tsx}", + ], + darkMode: "class", + presets: [require("@premieroctet/next-admin/dist/preset")], +}; +``` -const { run } = createAppHandler({ - options, - prisma, - schema, -}); +#### Prisma + +Add the `jsonSchema` generator to your `schema.prisma` file -export { run as POST, run as GET, run as DELETE }; +```prisma copy +// prisma/schema.prisma +generator jsonSchema { + provider = "prisma-json-schema-generator" + includeRequiredFields = "true" +} ``` -Then configure `page.tsx` +Then run the following command : + +```bash copy +yarn run prisma generate +``` + +#### App router + +Configure `page.tsx` in the `app/admin/[[...nextadmin]]` folder -```tsx +```tsx copy // app/admin/[[...nextadmin]]/page.tsx -import { NextAdmin } from "@premieroctet/next-admin"; -import { getPropsFromParams } from "@premieroctet/next-admin/dist/appRouter"; -import "@premieroctet/next-admin/dist/styles.css"; -import Dashboard from "../../../components/Dashboard"; -import { options } from "../../../options"; -import { prisma } from "../../../prisma"; -import schema from "../../../prisma/json-schema/json-schema.json"; // generated by prisma-json-schema-generator on yarn run prisma generate - -export default async function AdminPage({ - params, - searchParams, -}: { - params: { [key: string]: string[] }; - searchParams: { [key: string]: string | string[] | undefined } | undefined; -}) { - const props = await getPropsFromParams({ +import { NextAdmin, PageProps } from "@premieroctet/next-admin"; +import { getNextAdminProps } from "@premieroctet/next-admin/dist/appRouter"; +import { prisma } from "@/prisma"; +import schema from "@/prisma/json-schema/json-schema.json"; +import "@/styles.css"; // .css file containing tailiwnd rules + +export default async function AdminPage({ params, searchParams }: PageProps) { + const props = await getNextAdminProps({ params: params.nextadmin, searchParams, - options, + basePath: "/admin", + apiBasePath: "/api/admin", prisma, schema, + /*options*/ }); - return ; + return ; } ``` -#### Page Router +Configure `route.ts` in the `app/api/[[...nextadmin]]` folder -```tsx -// pages/api/admin/[[...nextadmin]].ts -import { options } from "@/pageRouterOptions"; +```ts copy +// app/api/admin/[[...nextadmin]]/route.ts import { prisma } from "@/prisma"; -import { createApiRouter } from "@premieroctet/next-admin/dist/apiRoute"; import schema from "@/prisma/json-schema/json-schema.json"; +import { createHandler } from "@premieroctet/next-admin/dist/appHandler"; -// --> IMPORTANT OR WE CANNOT HANDLE FORMDATA <-- -export const config = { - api: { - bodyParser: false, - }, -}; - -const { run } = createApiRouter({ - options, +const { run } = createHandler({ + apiBasePath: "/api/admin", prisma, - schema: schema, + schema, + /*options*/ }); -export default run; +export { run as DELETE, run as GET, run as POST }; ``` -```tsx -import { PrismaClient } from "@prisma/client"; -import schema from "./../../../prisma/json-schema/json-schema.json"; // generated by prisma-json-schema-generator on yarn run prisma generate -import "@premieroctet/next-admin/dist/styles.css"; -import { - AdminComponentProps, - NextAdmin, - NextAdminOptions, -} from "@premieroctet/next-admin"; +#### Start the server -const prisma = new PrismaClient(); +Run the following command to start the server: -export default function Admin(props: AdminComponentProps) { - return ; -} +```bash copy +yarn dev +``` -export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { - const { nextAdminRouter } = await import( - "@premieroctet/next-admin/dist/router" - ); +## 📄 Documentation - const adminRouter = await nextAdminRouter(prisma, schema); - return adminRouter.run(req, res) as Promise< - GetServerSidePropsResult<{ [key: string]: any }> - >; -}; -``` +For detailed documentation, please refer to the [documentation](https://next-admin-docs.vercel.app/). -## Demonstration +## 🚀 Demonstration You can find the library code in the [next-admin](https://github.com/premieroctet/next-admin) repository. diff --git a/packages/next-admin/src/components/Message.tsx b/packages/next-admin/src/components/Message.tsx index 0dff5301..0ac48911 100644 --- a/packages/next-admin/src/components/Message.tsx +++ b/packages/next-admin/src/components/Message.tsx @@ -5,10 +5,16 @@ import { XCircleIcon, } from "@heroicons/react/24/outline"; import clsx from "clsx"; +import { useEffect } from "react"; import { useMessage } from "../context/MessageContext"; const Message = (props: React.HTMLAttributes) => { - const { message } = useMessage(); + const { message, hideMessage } = useMessage(); + useEffect(() => { + if (message) { + window.scrollTo({ top: 0, behavior: "smooth" }); + } + }, [hideMessage, message]); return ( message && ( diff --git a/packages/next-admin/tsconfig.json b/packages/next-admin/tsconfig.json index 003214d6..83d22edf 100644 --- a/packages/next-admin/tsconfig.json +++ b/packages/next-admin/tsconfig.json @@ -5,7 +5,6 @@ "src/index.ts", "./src/appRouter.ts", "./src/actions/index.ts", - "./src/mainLayout.tsx", "./src/plugin.ts", "./src/preset.ts", "./src/appHandler.ts", From c61762b1507b4b3dffe1310787f91d2fb953a49c Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Mon, 24 Jun 2024 17:04:33 +0200 Subject: [PATCH 20/89] Add previous doc to nextra --- apps/docs/pages/_meta.json | 5 + apps/docs/pages/docs/api-docs.mdx | 6 +- apps/docs/pages/docs/code-snippets.mdx | 9 +- apps/docs/pages/docs/edge-cases.mdx | 10 +- apps/docs/pages/docs/getting-started.mdx | 4 + apps/docs/pages/docs/glossary.mdx | 6 + apps/docs/pages/docs/i18n.mdx | 9 +- apps/docs/pages/docs/index.mdx | 6 + apps/docs/pages/docs/route.mdx | 6 + apps/docs/pages/docs/theming.mdx | 6 + apps/docs/pages/v4/_meta.json | 7 + apps/docs/pages/v4/docs/_meta.json | 12 + apps/docs/pages/v4/docs/api-docs.mdx | 488 ++++++++++++++++++++ apps/docs/pages/v4/docs/code-snippets.mdx | 234 ++++++++++ apps/docs/pages/v4/docs/edge-cases.mdx | 62 +++ apps/docs/pages/v4/docs/getting-started.mdx | 241 ++++++++++ apps/docs/pages/v4/docs/glossary.mdx | 23 + apps/docs/pages/v4/docs/i18n.mdx | 91 ++++ apps/docs/pages/v4/docs/index.mdx | 28 ++ apps/docs/pages/v4/docs/route.mdx | 16 + apps/docs/pages/v4/docs/theming.mdx | 61 +++ 21 files changed, 1323 insertions(+), 7 deletions(-) create mode 100644 apps/docs/pages/v4/_meta.json create mode 100644 apps/docs/pages/v4/docs/_meta.json create mode 100644 apps/docs/pages/v4/docs/api-docs.mdx create mode 100644 apps/docs/pages/v4/docs/code-snippets.mdx create mode 100644 apps/docs/pages/v4/docs/edge-cases.mdx create mode 100644 apps/docs/pages/v4/docs/getting-started.mdx create mode 100644 apps/docs/pages/v4/docs/glossary.mdx create mode 100644 apps/docs/pages/v4/docs/i18n.mdx create mode 100644 apps/docs/pages/v4/docs/index.mdx create mode 100644 apps/docs/pages/v4/docs/route.mdx create mode 100644 apps/docs/pages/v4/docs/theming.mdx diff --git a/apps/docs/pages/_meta.json b/apps/docs/pages/_meta.json index 4671f7c4..d1d7d236 100644 --- a/apps/docs/pages/_meta.json +++ b/apps/docs/pages/_meta.json @@ -12,6 +12,11 @@ "type": "page", "display": "hidden" }, + "v4": { + "title": "v4", + "type": "page", + "display": "hidden" + }, "changelog": { "title": "Changelog", "type": "page" diff --git a/apps/docs/pages/docs/api-docs.mdx b/apps/docs/pages/docs/api-docs.mdx index 77a7cb1a..aae9d8a9 100644 --- a/apps/docs/pages/docs/api-docs.mdx +++ b/apps/docs/pages/docs/api-docs.mdx @@ -1,7 +1,11 @@ -import { Tabs } from "nextra/components"; +import { Tabs, Callout } from "nextra/components"; # API + + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + + ## Functions diff --git a/apps/docs/pages/docs/code-snippets.mdx b/apps/docs/pages/docs/code-snippets.mdx index 5051d303..d0c12300 100644 --- a/apps/docs/pages/docs/code-snippets.mdx +++ b/apps/docs/pages/docs/code-snippets.mdx @@ -1,5 +1,11 @@ +import { Callout, Tabs } from "nextra/components"; + # Code snippets + + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + + This page contains code snippets that you can use in your projects. These are not a part of Next Admin, but may be useful for your projects. Some of the snippets are implemented in the example project. You can check them out in the [example project](https://next-admin-po.vercel.app) or in the [source code](https://github.com/premieroctet/next-admin/tree/main/apps/example). @@ -123,7 +129,6 @@ Some of the snippets are implemented in the example project. You can check them - ## Export data By using [exports](/docs/api-docs#exports-property) options, you can export data. Next Admin only implements the CSV export button, it's actually a link pointing to a provided url. @@ -237,8 +242,6 @@ export async function POST(req: NextRequest, context: any) { } export { run as DELETE, run as GET }; - - ``` > Note that this example assumes that you have a `createdBy` and `updatedBy` field on each model, if you need to check the model name, you can use `params.params[0]`. diff --git a/apps/docs/pages/docs/edge-cases.mdx b/apps/docs/pages/docs/edge-cases.mdx index 79f79798..134e46a9 100644 --- a/apps/docs/pages/docs/edge-cases.mdx +++ b/apps/docs/pages/docs/edge-cases.mdx @@ -1,5 +1,12 @@ +import { Callout } from "nextra/components"; + # Edge cases + + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + + + In this part, we will talk about the edge cases we found during the development of the project. Prisma allows different structures to define models and relations. We had to choose which to exploit and which to avoid. Some choices may evolve in the future. @@ -22,7 +29,6 @@ model User { Prisma allows fields to be generated automatically. For example, the `id` field is automatically generated by Prisma. These fields are not editable by the user, neither during creation nor during update. - ## Relations ### One to one @@ -53,4 +59,4 @@ Prisma allows two types of [many-to-many](https://www.prisma.io/docs/concepts/co ### Self relation -Prisma allows [self relations](https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations). All the self relations are supported by the library. \ No newline at end of file +Prisma allows [self relations](https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations). All the self relations are supported by the library. diff --git a/apps/docs/pages/docs/getting-started.mdx b/apps/docs/pages/docs/getting-started.mdx index 7c8080c3..66ddccea 100644 --- a/apps/docs/pages/docs/getting-started.mdx +++ b/apps/docs/pages/docs/getting-started.mdx @@ -2,6 +2,10 @@ import { Callout, Steps, Tabs } from "nextra/components"; # Getting Started + + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + + The following guide will help you get started with Next-Admin. diff --git a/apps/docs/pages/docs/glossary.mdx b/apps/docs/pages/docs/glossary.mdx index 44ccfce1..14dd1d2a 100644 --- a/apps/docs/pages/docs/glossary.mdx +++ b/apps/docs/pages/docs/glossary.mdx @@ -1,3 +1,9 @@ +import { Callout } from "nextra/components"; + + + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + + In this documentation we will use the following conventions: ## List view diff --git a/apps/docs/pages/docs/i18n.mdx b/apps/docs/pages/docs/i18n.mdx index 2db764c2..54130589 100644 --- a/apps/docs/pages/docs/i18n.mdx +++ b/apps/docs/pages/docs/i18n.mdx @@ -1,5 +1,11 @@ +import { Callout } from "nextra/components"; + # I18n + + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + + Next Admin supports i18n with the `translations` prop of the `NextAdmin` component. The following keys are accepted: @@ -69,7 +75,8 @@ By using the `model` key in the translations object, you can translate the model ```json { "model": { - "User": { // Keep the case sensitive name of the model + "User": { + // Keep the case sensitive name of the model "name": "User", "plural": "Users", "fields": { diff --git a/apps/docs/pages/docs/index.mdx b/apps/docs/pages/docs/index.mdx index 8c6d7631..30967cfe 100644 --- a/apps/docs/pages/docs/index.mdx +++ b/apps/docs/pages/docs/index.mdx @@ -1,5 +1,11 @@ +import { Callout } from "nextra/components"; + # 🧞 Next Admin + + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + + ###### `next-admin` is a library built on top of [Prisma](https://www.prisma.io/) and [Next.js](https://nextjs.org/) that allows you to easily manage and visualize your Prisma database in a nice GUI. Get started by following the [installation guide](/docs/getting-started) or check out the [live demo](https://next-admin-po.vercel.app/admin). diff --git a/apps/docs/pages/docs/route.mdx b/apps/docs/pages/docs/route.mdx index fa75c658..6f1184b6 100644 --- a/apps/docs/pages/docs/route.mdx +++ b/apps/docs/pages/docs/route.mdx @@ -1,5 +1,11 @@ +import { Callout } from "nextra/components"; + # Customize the admin route name + + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + + When setting up `next-admin` you can set the admin panel route name to whatever you want. The admin panel route name is set by your folder name. diff --git a/apps/docs/pages/docs/theming.mdx b/apps/docs/pages/docs/theming.mdx index cc6125a8..b353580b 100644 --- a/apps/docs/pages/docs/theming.mdx +++ b/apps/docs/pages/docs/theming.mdx @@ -1,5 +1,11 @@ +import { Callout } from "nextra/components"; + # Theming + + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + + Next Admin comes with a preset that is used to theme the different components of the pages. You can add the preset to your Tailwind config presets : diff --git a/apps/docs/pages/v4/_meta.json b/apps/docs/pages/v4/_meta.json new file mode 100644 index 00000000..459c2983 --- /dev/null +++ b/apps/docs/pages/v4/_meta.json @@ -0,0 +1,7 @@ +{ + "docs": { + "title": "Documentation", + "type": "page", + "display": "hidden" + } +} \ No newline at end of file diff --git a/apps/docs/pages/v4/docs/_meta.json b/apps/docs/pages/v4/docs/_meta.json new file mode 100644 index 00000000..31f0e073 --- /dev/null +++ b/apps/docs/pages/v4/docs/_meta.json @@ -0,0 +1,12 @@ + +{ + "index": "Introduction", + "getting-started": "Getting Started", + "api-docs": "API", + "i18n": "I18n", + "theming": "Theming", + "glossary": "Glossary", + "route": "Route name", + "edge-cases": "Edge cases", + "code-snippets": "Code snippets" +} diff --git a/apps/docs/pages/v4/docs/api-docs.mdx b/apps/docs/pages/v4/docs/api-docs.mdx new file mode 100644 index 00000000..dfb684c2 --- /dev/null +++ b/apps/docs/pages/v4/docs/api-docs.mdx @@ -0,0 +1,488 @@ +import { Callout, Tabs } from "nextra/components"; + +# API + + + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + + +## Functions + + + + The following is used only for App router. + + ## `getPropsFromParams` function + + `getPropsFromParams` is a function that returns the props for the [`NextAdmin`](#nextadmin--component) component. It accepts one argument which is an object with the following properties: + + - `params`: the array of route params retrieved from the [optional catch-all segment](https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes#optional-catch-all-segments) + - `searchParams`: the query params [retrieved from the page](https://nextjs.org/docs/app/api-reference/file-conventions/page#searchparams-optional) + - `options`: the [options](#next-admin-options) object + - `schema`: the json schema generated by the `prisma generate` command + - `prisma`: your Prisma client instance + - `action`: the [server action](https://nextjs.org/docs/app/api-reference/functions/server-actions) used to submit the form. It should be your own action, that wraps the `submitForm` action imported from `@premieroctet/next-admin/dist/actions`. + - `deleteAction`: the [server action](https://nextjs.org/docs/app/api-reference/functions/server-actions) used to delete one or more records in a resource. It is optional, and should be your own action. This action takes 3 parameters: `model` (the model name) and `ids` (an array of ids to delete). Next Admin provides a default action for deletion, that you can call in your own action. Check the example app for more details. + - `getMessages`: a function with no parameters that returns translation messages. It is used to translate the default messages of the library. See [i18n](/docs/i18n) for more details. + - `searchPaginatedResourceAction`: the [server action](https://nextjs.org/docs/app/api-reference/functions/server-actions) used to search for resources in a selector widget. This is mandatory for App Router, and will be ignored on page router. Just like `action`, it should be your own action that wraps `searchPaginatedResource` imported from `@premieroctet/next-admin/dist/actions`. + + + + The following is used only for Page router + + ## `nextAdminRouter` function + + `nextAdminRouter` is a function that returns a promise of a _Node Router_ that you can use in your getServerSideProps function to start using Next Admin. Its usage is only related to Page router. + + Usage example: + + ```ts copy + // pages/api/admin/[[...nextadmin]].ts + export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { + const { nextAdminRouter } = await import( + "@premieroctet/next-admin/dist/router" + ); + const adminRouter = await nextAdminRouter(prisma, schema); + return adminRouter.run(req, res) as Promise< + GetServerSidePropsResult<{ [key: string]: any }> + >; + }; + ``` + + It takes 3 parameters: + + - Your Prisma client instance, _required_ + - Your Prisma schema, _required_ + + and an _optional_ object of type [`NextAdminOptions`](#next-admin-options) to customize your admin with the following properties: + + ```ts + import { NextAdminOptions } from "@premieroctet/next-admin"; + + const options: NextAdminOptions = { + model: { + User: { + toString: (user) => `${user.email} / ${user.name}`, + }, + }, + }; + + const adminRouter = await nextAdminRouter(prisma, schema, options); + ``` + + + + +## Authentication + + + + The library does not provide an authentication system. If you want to add your own, you can do so by adding a role check in the page: + + > The following example uses [next-auth](https://next-auth.js.org/) to handle authentication + + ```ts + // app/admin/[[...nextadmin]]/page.tsx + + export default async function AdminPage({ + params, + searchParams, + }: { + params: { [key: string]: string[] }; + searchParams: { [key: string]: string | string[] | undefined } | undefined; + }) { + const session = await getServerSession(authOptions); + const isAdmin = session?.user?.role === "SUPERADMIN"; // your role check + + if (!isAdmin) { + redirect('/', { permanent: false }) + } + + const props = await getPropsFromParams({ + params: params.nextadmin, + searchParams, + options, + prisma, + schema, + action: submitFormAction, + }); + + return ; + } + ``` + + + + The library does not provide an authentication system. If you want to add your own, you can do so by adding a role check to the `getServerSideProps` function: + + > The following example uses [next-auth](https://next-auth.js.org/) to handle authentication + + ```ts + // pages/api/admin/[[...nextadmin]].ts + + export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { + const session = await getServerSession(req, res, authOptions); + const isAdmin = session?.user?.role === "SUPERADMIN"; // your role check + + if (!isAdmin) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + + const { nextAdminRouter } = await import( + "@premieroctet/next-admin/dist/nextAdminRouter" + ); + return nextAdminRouter(client).run(req, res); + }; + ``` + + + + +## `` component + +`` is a React component that contains the entire UI of Next Admin. It can take several props: + +- `AdminComponentProps`, which are passed by the [router function](#nextadminrouter-function) via getServerSideProps +- `options` used to customize the UI, like field formatters for example. Do not use with App router. +- `dashboard` used to customize the rendered dashboard +- `translations` used to customize some of the texts displayed in the UI. See [i18n](/docs/i18n) for more details. +- `user` used to add some user information at the bottom of the menu. See [user properties](#user-properties) for more details. + +> ⚠️ : Do not override these `AdminComponentProps` props, they are used internally by Next Admin. + +This is an example of using the `NextAdmin` component with a custom Dashboard component and options: + +```tsx +// pages/admin/[[...nextadmin]].tsx +import Dashboard from "../../components/CustomDashboard"; + +export default function Admin(props: AdminComponentProps) { + /* Props are passed from the nextAdminRouter function via getServerSideProps */ + return ( + { + return {role.toString()}; + }, + }, + birthDate: { + formatter: (date) => { + return new Date(date as unknown as string) + ?.toLocaleString() + .split(" ")[0]; + }, + }, + }, + }, + }, + }, + }} + /> + ); +} +``` + +## Next Admin Options + +Next Admin options is the third parameter of the [router function](#nextadminrouter-function) and it's an object of options that has the following properties: + +### `basePath` + +`basePath` is a string that represents the base path of your admin. (e.g. `/admin`) - optional. + +### `model` + +`model` is an object that represents the customization options for each model in your schema. + +It takes as **_key_** a model name of your schema as **_value_** an object to customize your it. + +By default if no models are defined, they will all be displayed in the admin. If you want more control, you have to define each model individually as empty objects or with the following properties: + +| Name | Description | Default value | +| ------------- | --------------------------------------------------------------------------------------------------- | ---------------------------- | +| `toString` | a function that is used to display your record in related list | `id` field | +| `aliases` | an object containing the aliases of the model fields as keys, and the field name | undefined | +| `title` | a string used to display the model name in the sidebar and in the section title | Model name | +| `list` | an object containing the [list options](#list-property) | undefined | +| `edit` | an object containing the [edit options](#edit-property) | undefined | +| `actions` | an array of [actions](#actions-property) | undefined | +| `icon` | the [outline HeroIcon name](https://heroicons.com/outline) displayed in the sidebar and pages title | undefined | +| `permissions` | an array to specify restricted permissions on model | [`create`, `edit`, `delete`] | + +You can customize the following for each model: + +- ##### `list` property + +This property determines how your data is displayed in the [List View](/docs/glossary#list-view) + +| Name | Description | Default value | +| ----------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------- | +| `search` | an array of searchable fields | undefined - all scalar fields are searchable | +| `display` | an array of fields that are displayed in the list | undefined - all scalar fields are displayed | +| `fields` | an object containing the model fields as keys, and customization values, see [below](#fields-property) | undefined | +| `copy` | an array of fields that are copyable into the clipboard | undefined - no field is copyable by default | +| `defaultSort` | an optional object to determine the default sort to apply on the list | undefined | +| `defaultSort.field` | the model's field name on which the sort is applied. It is mandatory | | +| `defaultSort.direction` | the sort direction to apply. It is optional | | +| `filters` | define a set of Prisma filters that user can choose in list, see [below](#filters-property) | undefined | +| `exports` | an object or array of [export](#exports-property) - containing export url | undefined | + +> Note that the `search` property is only available for `scalar` fields. + +- ##### `edit` property + +This property determines how your data is displayed in the [edit view](/docs/glossary#edit-view) + +| Name | Description | Default value | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| `display` | an array of fields that are displayed in the form. It can also be an object that will be displayed in the form of a notice. See [notice](#notice) | all scalar fields are displayed | +| `styles` | an object containing the styles of the form | undefined | +| `fields` | an object containing the model fields as keys, and customization values | undefined | +| `submissionErrorMessage` | a message displayed if an error occurs during the form submission, after the form validation and before any call to prisma | Submission error | + +##### `styles` property + +The `styles` property is available in the `edit` property. + +> ⚠️ If your options are defined in a separate file, make sure to add the path to the `content` property of the `tailwind.config.js` file + +| Name | Description | +| ------- | ------------------------------------------------------------------------------ | +| `_form` | a string defining the classname of the form | +| ... | all fields of the model, with the field name as key and the classname as value | + +Here is an example of using `styles` property: + +```ts +styles: { + _form: "form-classname", + ... // all fields +}; +``` + +##### `fields` property + +The `fields` property is available in both `list` and `edit` properties. + +For the `list` property, it can take the following: + +| Name | Description | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `formatter` | a function that takes the field value as a parameter, and that return a JSX node. It also accepts a second argument which is the [`NextAdmin` context](#nextadmin-context) | +| `sortBy` | available only on many-to-one models. The name of a field of the related model to apply the sort on. Defaults to the id field of the related model. | + +For the `edit` property, it can take the following: + +| Name | Description | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `validate` | a function that takes the field value as a parameter, and that returns a boolean | +| `format` | a string defining an OpenAPI field format, overriding the one set in the generator. An extra `file` format can be used to be able to have a file input | +| `input` | a React Element that should receive [CustomInputProps](#custominputprops). For App Router, this element must be a client component. | +| `handler` | an object that can take the following properties | +| `handler.get` | a function that takes the field value as a parameter and returns a transformed value displayed in the form | +| `handler.upload` | an async function that is used only for formats `file` and `data-url`. It takes a File object as parameter and must return a string. Useful to upload a file to a remote provider | +| `handler.uploadErrorMessage` | an optional string displayed in the input field as an error message in case of a failure during the upload handler | +| `optionFormatter` | only for relation fields, a function that takes the field values as a parameter and returns a string. Useful to display your record in related list | +| `tooltip` | a tooltip content to show for the field | +| `helperText` | a helper text that is displayed underneath the input | +| `disabled` | a boolean to indicate that the field is read only | +| `display` | only for relation fields, indicate which display format to use between `list`, `table` or `select`. Default `select` | +| `required` | a true value to force a field to be required in the form, note that if the field is required by the Prisma schema, you cannot set `required` to false | +| `relationOptionFormatter` | same as `optionFormatter`, but used to format data that comes from an [explicit many-to-many](https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/many-to-many-relations#explicit-many-to-many-relations) relationship. See [handling explicit many-to-many](/docs/code-snippets#explicit-many-to-many) | +| `relationshipSearchField` | a field name of the explicit many-to-many relation table to apply the search on. See [handling explicit many-to-many](/docs/code-snippets#explicit-many-to-many) | + +##### `filters` property + +The `filters` property allow you to define a set of Prisma filters that user can apply on list page. It's an array of the type below: + +| Name | Description | Default value | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| `name` | an unique name for the filter | undefined | +| `active` | a boolean to set filter active by default | false | +| `value` | a where clause Prisma filter of the related model (e.g [Prisma operators](https://www.prisma.io/docs/orm/reference/prisma-client-reference#filter-conditions-and-operators)) | | + +##### `exports` property + +The `exports` property is available in the `list` property. It's an object or an array of objects that can take the following properties: + +| Name | Description | Default value | +| -------- | ----------------------------------------------------------------------------------- | ------------- | +| `format` | a string defining the format of the export. It's used to labeled the export button. | undefined | +| `url` | a string defining the URL of the export action. | undefined | + +> Note that the `exports` property do not take care of active filters. If you want to export filtered data, you have to add the filters in the URL or in your export action. + +### `pages` + +`pages` is an object that allows you to add your own sub pages as a sidebar menu entry. It is an object where the key is the path (without the base path) and the value is an object with the following properties: + +| Name | Description | +| ------- | ----------------------------------------------------------------------------------------------- | +| `title` | the title of the page displayed on the sidebar | +| `icon` | the [outline HeroIcon name](https://heroicons.com/outline) of the page displayed on the sidebar | + +#### `actions` property + +The `actions` property is an array of objects that allows you to define a set of actions that can be executed on one or more records of the resource. On the list view, there is a default action for deletion. The object can take the following properties: + +| Name | Description | Default value | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | +| `title` | action title that will be shown in the action dropdown | undefined | +| `action` | an async function that will be triggered when selecting the action in the dropdown. For App Router, it must be defined as a server action. | undefined | +| `successMessage` | a message that will be displayed when the action is successful | undefined | +| `errorMessage` | a message that will be displayed when the action fails | undefined | + +#### `sidebar` property + +The `sidebar` property allows you to customise the aspect of the sidebar menu. It is an object that can take the following properties: + +| Name | Description | Default value | +| ----------------- | --------------------------------------------------------------------------------------------------- | ------------- | +| `groups` | an array of objects that creates groups for specific resources. It takes the following properties : | | +| `groups[].title` | the name of the group | | +| `groups[].models` | the model names to display in the group | | + +#### `externalLinks` property + +The `externalLinks` property allows you to add external links to the sidebar menu. It is an array of objects that can take the following properties: + +| Name | Description | +| ------- | ----------------------------------------------------------------- | +| `label` | the label of the link displayed on the sidebar. This is mandatory | +| `url` | the URL of the link. This is mandatory | + +### `defaultColorScheme` property + +The `defaultColorScheme` property defines a default color palette between `light`, `dark` and `system`, but allows the user to modify it. Default to `system`. + +### `forceColorScheme` property + +Identical to `defaultColorScheme` but does not allow the user to change it. + +Here is an example of using `NextAdminOptions` for the following schema : + +```prisma +// prisma/schema.prisma +enum Role { + USER + ADMIN +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + password String @default("") + posts Post[] @relation("author") // One-to-many relation + profile Profile? @relation("profile") // One-to-one relation + birthDate DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + role Role @default(USER) +} +``` + +```tsx +// pages/api/admin/[[...nextadmin]].ts +const options: NextAdminOptions = { + basePath: "/admin", + model: { + User: { + toString: (user) => `${user.name} (${user.email})`, + list: { + display: ["id", "name", "email", "posts", "role", "birthDate"], + search: ["name", "email"], + fields: { + role: { + formatter: (role) => { + return {role.toString()}; + }, + }, + birthDate: { + formatter: (date) => { + return new Date(date as unknown as string) + ?.toLocaleString() + .split(" ")[0]; + }, + }, + }, + }, + edit: { + display: ["id", "name", "email", "posts", "role", "birthDate"], + fields: { + email: { + validate: (email) => email.includes("@") || "Invalid email", + }, + birthDate: { + format: "date", + }, + avatar: { + format: "file", + handler: { + upload: async (file: File) => { + return "https://www.gravatar.com/avatar/00000000000000000000000000000000"; + }, + }, + }, + }, + }, + }, + }, +}; + +const adminRouter = await nextAdminRouter(prisma, schema, options); +``` + +## CustomInputProps + +This is the type of the props that are passed to the custom input component: + +| Name | Description | +| ----------- | --------------------------------------------------------------------------------------------------------------------------- | +| `name` | the field name | +| `value` | the field value | +| `onChange` | a function taking a [ChangeEvent](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/change_event) as a parameter | +| `readonly` | boolean indicating if the field is editable or not | +| `rawErrors` | array of strings containing the field errors | +| `disabled` | boolean indicating if the field is disabled | + +## NextAdmin Context + +The `NextAdmin` context is an object containing the following properties: + +- `locale`: the locale used by the calling page. (refers to the `accept-language` header). +- `row`: the current row of the list view. Represents non-formatted data of the current row. + +## Notice + +The edit page's form can display notice alerts. To do so, you can pass objects in the `display` array of the `edit` property of a model. This object takes the following properties : + +| Name | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------- | +| `title` | The title of the notice. This is mandatory | +| `id` | A unique identifier for the notice that can be used to style it with the `styles` property. This is mandatory | +| `description` | The description of the notice. This is optional | + +## User properties + +The `user` property is an object that can take the following properties: + +| Name | Description | +| -------------- | -------------------------------------------------------------------- | +| `data.name` | the name of the user displayed in the sidebar menu. This is required | +| `data.picture` | the URL of the user's avatar displayed in the sidebar menu | +| `logoutUrl` | an URL or path to logout the user. This is required. | diff --git a/apps/docs/pages/v4/docs/code-snippets.mdx b/apps/docs/pages/v4/docs/code-snippets.mdx new file mode 100644 index 00000000..f6a2d6f3 --- /dev/null +++ b/apps/docs/pages/v4/docs/code-snippets.mdx @@ -0,0 +1,234 @@ +import { Callout } from "nextra/components"; + +# Code snippets + + + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + + +This page contains code snippets that you can use in your projects. These are not a part of Next Admin, but may be useful for your projects. + +Some of the snippets are implemented in the example project. You can check them out in the [example project](https://next-admin-po.vercel.app) or in the [source code](https://github.com/premieroctet/next-admin/tree/main/apps/example). + +## Export data + +By using [exports](/docs/api-docs#exports-property) options, you can export data. Next Admin only implements the CSV export button, it's actually a link pointing to a provided url. + +The API endpoint route must be defined in the `exports` property of the options object. This is an example of how to implement the export API endpoint: + +```typescript copy +// app/api/users/export/route.ts +import { prisma } from "@/prisma"; + +export async function GET() { + const users = await prisma.user.findMany(); + const csv = users.map((user) => { + return `${user.id},${user.name},${user.email},${user.role},${user.birthDate}`; + }); + + const headers = new Headers(); + headers.set("Content-Type", "text/csv"); + headers.set("Content-Disposition", `attachment; filename="users.csv"`); + + return new Response(csv.join("\n"), { + headers, + }); +} +``` + +or with a stream response: + +```typescript copy +// app/api/users/export/route.ts +import { prisma } from "@/prisma"; +const BATCH_SIZE = 1000; + +export async function GET() { + const headers = new Headers(); + headers.set("Content-Type", "text/csv"); + headers.set("Content-Disposition", `attachment; filename="users.csv"`); + + const stream = new ReadableStream({ + async start(controller) { + try { + const batchSize = BATCH_SIZE; + let skip = 0; + let users; + + do { + users = await prisma.user.findMany({ + skip, + take: batchSize, + }); + + const csv = users + .map((user) => { + return `${user.id},${user.name},${user.email},${user.role},${user.birthDate}\n`; + }) + .join(""); + + controller.enqueue(Buffer.from(csv)); + + skip += batchSize; + } while (users.length === batchSize); + } catch (error) { + controller.error(error); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { headers }); +} +``` + +> Note that you must secure the export route if you don't want to expose your data to the public by adding authentication middleware. + +There is two example files in the example project: + +- [app/api/users/export/route.ts](https://github.com/premieroctet/next-admin/tree/main/apps/example/app/api/users/export/route.ts) +- [app/api/posts/export/route.ts](https://github.com/premieroctet/next-admin/tree/main/apps/example/app/api/posts/export/route.ts) + +## Add data to formData before submitting + +If you want to add data to the form data before submitting it, you can add logic to the `submitFormAction` function. This is an example of how to add `createdBy` and `updatedBy` fields based on the user id: + +```typescript copy +// actions/nextadmin.ts +"use server"; +import { ActionParams } from "@premieroctet/next-admin"; +import { submitForm } from "@premieroctet/next-admin/dist/actions"; + +export const submitFormAction = async ( + params: ActionParams, + formData: FormData +) => { + const userId = /* get the user id */; + if (params.params[1] === "new") { + formData.append("createdBy", userId); + } else { + formData.append("updatedBy", userId); + } + + return submitForm({ ...params, options, prisma }, formData); +}; +``` + +> Note that this example assumes that you have a `createdBy` and `updatedBy` field on each model, if you need to check the model name, you can use `params.params[0]`. + +This snippet is not implemented in the example project. + +## Custom input form + +If you want to customize the input form, you can create a custom input component. This is an example of how to create a custom input component for the `birthDate` field: + +```typescript copy +// components/inputs/BirthDateInput.tsx +"use client"; +import { CustomInputProps } from "@premieroctet/next-admin"; +import DateTimePicker from "react-datepicker"; +import "react-datepicker/dist/react-datepicker.css"; + +type Props = CustomInputProps; + +const BirthDateInput = ({ value, name, onChange, disabled, required }: Props) => { + return ( + <> + + onChange?.({ + // @ts-expect-error + target: { value: date?.toISOString() ?? new Date().toISOString() }, + }) + } + showTimeSelect + dateFormat="dd/MM/yyyy HH:mm" + timeFormat="HH:mm" + wrapperClassName="w-full" + disabled={disabled} + required={required} + className="dark:bg-dark-nextadmin-background-subtle dark:ring-dark-nextadmin-border-strong text-nextadmin-content-inverted dark:text-dark-nextadmin-content-inverted ring-nextadmin-border-default focus:ring-nextadmin-brand-default dark:focus:ring-dark-nextadmin-brand-default block w-full rounded-md border-0 px-2 py-1.5 text-sm shadow-sm ring-1 ring-inset transition-all duration-300 placeholder:text-gray-400 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 sm:leading-6 [&>div]:border-none" + /> + + + ); +}; + +export default BirthDateInput; +``` + +The `CustomInputProps` type is provided by Next Admin. + +> Note that we use a hidden input to store the value because the `DateTimePicker` component needs a different value format than what is expected by the form submission. + +You can find an example of this component in the example project: + +- [app/components/inputs/DatePicker.tsx](https://github.com/premieroctet/next-admin/blob/main/apps/example/components/DatePicker.tsx) + +# Explicit many-to-many + +You might want to add sorting on a relationship, for example sort the categories of a post in a specific order. To achieve this, you have to explicitly define a model in the Prisma schema that will act as the join table. This is an example of how to implement this: + +```prisma {8,16,21,22,23,24,25,26,27,28,29,30} +model Post { + id Int @id @default(autoincrement()) + title String + content String? + published Boolean @default(false) + author User @relation("author", fields: [authorId], references: [id]) // Many-to-one relation + authorId Int + categories CategoriesOnPosts[] + rate Decimal? @db.Decimal(5, 2) + order Int @default(0) +} + +model Category { + id Int @id @default(autoincrement()) + name String + posts CategoriesOnPosts[] + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt +} + +model CategoriesOnPosts { + id Int @default(autoincrement()) + post Post @relation(fields: [postId], references: [id]) + postId Int + category Category @relation(fields: [categoryId], references: [id]) + categoryId Int + order Int @default(0) + + @@id([postId, categoryId]) +} +``` + +In the Next Admin options, you will then need to define, on a specific field, which field in the join table will be used for sorting: + +```typescript {11,12} copy +{ + model: { + Post: { + edit: { + fields: { + categories: { + relationOptionFormatter: (category) => { + return `${category.name} Cat.${category.id}`; + }, + display: "list", + orderField: "order", // The field used in CategoriesOnPosts for sorting + relationshipSearchField: "category", // The field to use in CategoriesOnPosts + }, + } + } + } + } +} +``` + +Note that you will need to use `relationOptionFormatter` instead of `optionFormatter` to format the content of the select input. + +With the `list` display, if the `orderField` property is defined, you will be able to apply drag and drop sorting on the categories. Upon form submission, the order will be updated accordingly, starting from 0. + +> The `orderField` property can also be applied for one-to-many relationships. In that case, drag and drop will also be available. diff --git a/apps/docs/pages/v4/docs/edge-cases.mdx b/apps/docs/pages/v4/docs/edge-cases.mdx new file mode 100644 index 00000000..ddaa3e92 --- /dev/null +++ b/apps/docs/pages/v4/docs/edge-cases.mdx @@ -0,0 +1,62 @@ +import { Callout } from 'nextra/components' + +# Edge cases + + + This documentation covers an older version of Next Admin. If you are using the latest version (`>=5.0.0` and above), please refer to the [current documentation](/docs). + + +In this part, we will talk about the edge cases we found during the development of the project. Prisma allows different structures to define models and relations. +We had to choose which to exploit and which to avoid. Some choices may evolve in the future. + +## Fields + +### `id` for identification + +We decided to use the `id` field to identify the models. This field is automatically generated by Prisma and is unique. The library doesn't support Prisma's composite keys. + +So this is the recommended way to identify models. + +```prisma +model User { + id Int @id @default(autoincrement()) + [...] +} +``` + +### Autogenerated fields + +Prisma allows fields to be generated automatically. For example, the `id` field is automatically generated by Prisma. These fields are not editable by the user, neither during creation nor during update. + + +## Relations + +### One to one + +Prisma allows one-to-one relations. But just one of the two models can have a relation field. If you want to remove the relation, you have to remove the field from the model that don't have the relation field. + +There is an example of one-to-one relation between `User` and `Profile` models. + +```prisma +model User { + id Int @id @default(autoincrement()) + profile Profile? @relation("profile") +} + +model Profile { + id Int @id @default(autoincrement()) + bio String? + user User? @relation("profile", fields: [userId], references: [id]) + userId Int? @unique +} +``` + +Even if the `userId` field is not required, you cannot remove the relationship between `User` and `Profile` without removing the `profile` field from the `user` model. + +### Many to many + +Prisma allows two types of [many-to-many](https://www.prisma.io/docs/concepts/components/prisma-schema/relations/many-to-many-relations) relationships: implicit and explicit. Both are supported in the library. But for better understanding in the Next Admin UI, we recommend to use implicit relations. + +### Self relation + +Prisma allows [self relations](https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations). All the self relations are supported by the library. \ No newline at end of file diff --git a/apps/docs/pages/v4/docs/getting-started.mdx b/apps/docs/pages/v4/docs/getting-started.mdx new file mode 100644 index 00000000..b91c9745 --- /dev/null +++ b/apps/docs/pages/v4/docs/getting-started.mdx @@ -0,0 +1,241 @@ +import { Callout, Tabs } from "nextra/components"; + +# Getting Started + + + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + + +## Installation + + + + ```bash +yarn add @premieroctet/next-admin prisma-json-schema-generator + ``` + + + + ```bash +npm install -S @premieroctet/next-admin prisma-json-schema-generator + ``` + + + ```bash +pnpm install -S @premieroctet/next-admin prisma-json-schema-generator + ``` + + + +## TailwindCSS configuration + +Next-Admin relies on [TailwindCSS](https://tailwindcss.com/) for the style. If you do not have it, you can [install TailwindCSS](https://tailwindcss.com/docs/installation) with the following config : + +```typescript +module.exports = { + content: [ + "./node_modules/@premieroctet/next-admin/dist/**/*.{js,ts,jsx,tsx}", + ], + darkMode: "class", + presets: [require("@premieroctet/next-admin/dist/preset")], +}; +``` + +Then import your `.css` file containing Tailwind rules into a page file or a parent layout. + +You can find more information about theming [here](/docs/theming) + +## SuperJson configuration + +SuperJson is required to avoid errors related to invalid serialisation properties that can occur when passing data from server to client. + +### With Babel + + + + ```bash +yarn add -D babel-plugin-superjson-next superjson@^1 + ``` + + + + ```bash +npm install --save-dev babel-plugin-superjson-next superjson@^1 + ``` + + + ```bash +pnpm install -D babel-plugin-superjson-next superjson@^1 + ``` + + + +Add the `babel-plugin-superjson-next` plugin to your `.babelrc` file: + +```json +{ + "presets": ["next/babel"], + "plugins": ["superjson-next"] +} +``` + +## With SWC (Experimental) + + + + ```bash +yarn add -E -D next-superjson-plugin@0.6.1 superjson + ``` + + + + ```bash +npm install --save-dev -E next-superjson-plugin@0.6.1 superjson + ``` + + + ```bash +pnpm install -E -D next-superjson-plugin@0.6.1 superjson + ``` + + + +Add the `next-superjson-plugin` plugin to your `next.config.js` file: + +```js +module.exports = { + // your current config + experimental: { + swcPlugins: [ + [ + "next-superjson-plugin", + { + excluded: [], + }, + ], + ], + }, +}; +``` + +## Quick Start + +Add the `prisma-json-schema-generator` generator to your `schema.prisma` file: + +```prisma +generator jsonSchema { + provider = "prisma-json-schema-generator" + includeRequiredFields = "true" +} +``` + +Then run the following command : + +```bash +yarn run prisma generate +``` + +This will create a `json-schema.json` file in the `prisma/json-schema` directory. + + + + ```tsx + // app/admin/[[...nextadmin]]/page.tsx + import { NextAdmin } from "@premieroctet/next-admin"; + import { getPropsFromParams } from "@premieroctet/next-admin/dist/appRouter"; + import Dashboard from "../../../components/Dashboard"; + import { options } from "../../../options"; + import { prisma } from "../../../prisma"; + import schema from "../../../prisma/json-schema/json-schema.json"; + import { submitFormAction } from "../../../actions/nextadmin"; + import "../../../styles.css" // .css file containing tailiwnd rules + + export default async function AdminPage({ + params, + searchParams, + }: { + params: { [key: string]: string[] }; + searchParams: { [key: string]: string | string[] | undefined } | undefined; + }) { + const props = await getPropsFromParams({ + params: params.nextadmin, + searchParams, + options, + prisma, + schema, + action: submitFormAction, + }); + + return ; + } + ``` + + + + Passing the `options` prop like you'd do on Page router will result in an error in case you + have functions defined inside the options object (formatter, handlers, etc.). + Make sure to pass no `options` prop at all. + + + + + + Make sure to not use `"use client"` in the page. + + + + You will also need to create the action: + + ```tsx + // actions/nextadmin.ts + "use server"; + import { ActionParams } from "@premieroctet/next-admin"; + import { submitForm } from "@premieroctet/next-admin/dist/actions"; + import { prisma } from "../prisma"; + import { options } from "../options"; + + export const submitFormAction = async ( + params: ActionParams, + formData: FormData + ) => { + return submitForm({ ...params, options, prisma }, formData); + }; + ``` + + + + ```tsx + // pages/admin/[[...nextadmin]].tsx + import { GetServerSideProps, GetServerSidePropsResult } from "next"; + import { NextAdmin, AdminComponentProps } from "@premieroctet/next-admin"; + import schema from "./../../prisma/json-schema/json-schema.json"; // import the json-schema.json file + import { PrismaClient } from "@prisma/client"; + import "../../../styles.css" // .css file containing tailiwnd rules + + const prisma = new PrismaClient(); + + export default function Admin(props: AdminComponentProps) { + return ; + } + + export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { + const { nextAdminRouter } = await import( + "@premieroctet/next-admin/dist/router" + ); + const adminRouter = await nextAdminRouter(prisma, schema); + return adminRouter.run(req, res) as Promise< + GetServerSidePropsResult<{ [key: string]: any }> + >; + }; + ``` + +The `nextAdminRouter` function accepts a third optional parameter, which is a Next Admin [options](/docs/api-docs#next-admin-options) object. + + + + +## Usage + +Once done, you can navigate to the `/admin` route. + +You should be able to see the admin dashboard. diff --git a/apps/docs/pages/v4/docs/glossary.mdx b/apps/docs/pages/v4/docs/glossary.mdx new file mode 100644 index 00000000..87f7309a --- /dev/null +++ b/apps/docs/pages/v4/docs/glossary.mdx @@ -0,0 +1,23 @@ +import { Callout } from "nextra/components"; + + + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + + +In this documentation we will use the following conventions: + +## List view + +Refers to the view that displays a table of model's records. + +## Edit view + +Refers to the view that allows you to edit a single record. + +## GUI + +Refers to the graphical user interface of the application. + +## Schema + +Refers to the [prisma schema](https://www.prisma.io/docs/concepts/components/prisma-schema) file that defines the data model of your application. diff --git a/apps/docs/pages/v4/docs/i18n.mdx b/apps/docs/pages/v4/docs/i18n.mdx new file mode 100644 index 00000000..78b71fac --- /dev/null +++ b/apps/docs/pages/v4/docs/i18n.mdx @@ -0,0 +1,91 @@ +import { Callout } from "nextra/components"; + +# I18n + + + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + + +Next Admin supports i18n with the `translations` prop of the `NextAdmin` component. + +The following keys are accepted: + +| Name | Description | Default value | +| -------------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| list.header.add.label | The "Add" button in the list header | Add | +| list.header.search.placeholder | The placeholder used in the search input | Search | +| list.footer.indicator.showing | The "Showing from" text in the list indicator, e.g: Showing from 1 to 10 of 25 | Showing from | +| list.footer.indicator.to | The "to" text in the list indicator, e.g: Showing from 1 to 10 of 25 | to | +| list.footer.indicator.of | The "of" text in the list indicator, e.g: Showing from 1 to 10 of 25 | of | +| list.row.actions.delete.label | The text in the delete button displayed at the end of each row | Delete | +| list.row.actions.delete.alert | The text in the native alert when the delete action is called in the list | Are you sure you want to delete \{\{count\}\} element(s)? | +| list.row.actions.delete.success | The text appearing after a successful deletion from the list | Deleted successfully | +| list.row.actions.delete.error | The text appearing after an error during the deletion from the list | An error occured while deleting | +| list.empty.label | The text displayed when there is no row in the list | No \{\{resource\}\} found | +| list.empty.caption | The caption displayed when there is no row in the list | Get started by creating a new \{\{resource\}\} | +| form.button.save.label | The text displayed in the form submit button | Submit | +| form.button.delete.label | The text displayed in the form delete button | Delete | +| form.delete.alert | The text displayed on the alert when the delete button is clicked | Are you sure you want to delete this? | +| form.widgets.file_upload.label | The text displayed in file upload widget to select a file | Choose a file | +| form.widgets.file_upload.drag_and_drop | The text displayed in file upload widget to indicate a drag & drop is possible | or drag and drop | +| form.widgets.file_upload.delete | The text displayed in file upload widget to delete the current file | Delete | +| form.widgets.multiselect.select | The text displayed in the multiselect widget in list display mode to toggle the select dialog | Select items | +| actions.label | The text displayed in the dropdown button for the actions list | Action | +| actions.edit.label | The text displayed for the default edit action in the actions dropdown | Edit | +| actions.create.label | The text displayed for the default create action in the actions dropdown | Create | +| actions.delete.label | The text displayed for the default delete action in the actions dropdown | Delete | +| selector.loading | The text displayed in the selector widget while loading the options | Loading... | +| user.logout | The text displayed in the logout button | Logout | +| model | Object to custom model and fields names. [More details](#translate-model-name-and-fields) | {} | + +There is two ways to translate these default keys, provide a function named `getMessages` inside the options or provide `translations` props to `NextAdmin` component. + +> Note that the function way allows you to provide an object with a multiple level structure to translate the keys, while the `translations` props only allows you to provide a flat object (`form.widgets.file_upload.delete` ex.) + +You can also pass your own set of translations. For example you can set a custom action name as a translation key, which will then be translated by the lib. + +```js +actions: [ + { + title: "actions.user.email", + action: async (...args) => { + "use server"; + const { submitEmail } = await import("./actions/nextadmin"); + await submitEmail(...args); + }, + successMessage: "actions.user.email.success", + errorMessage: "actions.user.email.error", + }, +], +``` + +Here, the `actions.user.email` key will be translated by the lib, and the value will be used as the action title, aswell as the success and error messages after the action's execution. + +Currently, you can only translate the following: + +- action title, success and error message +- field validation error message + +Check the example app for more details on the usage. + +## Translate model name and fields + +By using the `model` key in the translations object, you can translate the model name and fields. + +```json +{ + "model": { + "User": { + // Keep the case sensitive name of the model + "name": "User", + "plural": "Users", + "fields": { + "email": "Email", + "password": "Password" + } + } + } +} +``` + +> Note that if you only use one language for your admin, you should prefer to use the `alias` system ([more details](/docs/api-docs#model)) to customize field names. diff --git a/apps/docs/pages/v4/docs/index.mdx b/apps/docs/pages/v4/docs/index.mdx new file mode 100644 index 00000000..9d5b7137 --- /dev/null +++ b/apps/docs/pages/v4/docs/index.mdx @@ -0,0 +1,28 @@ +import { Callout } from "nextra/components"; + +# 🧞 Next Admin + + + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + + +###### `next-admin` is a library built on top of [Prisma](https://www.prisma.io/) and [Next.js](https://nextjs.org/) that allows you to easily manage and visualize your Prisma database in a nice GUI. + +Get started by following the [installation guide](/docs/getting-started) or check out the [live demo](https://next-admin-po.vercel.app/admin). + +![Hello](/screenshot.png) + +## ✨ Features + +This library is still under development. The following features are available: + +- 👀 Visualize, search and filter your data quickly +- 💅 Customizable admin dashboard +- 💽 Database relationships management +- 👩🏻‍💻 User management (CRUD operations) +- 🎨 Dashboard widgets and customizable panels +- ⚛️ Integration with Prisma ORM +- 👔 Customizable list and form +- ⚙️ Supports App Router and Page Router + +If you want to request a feature, please open an issue [here](https://github.com/premieroctet/next-admin/issues/new) diff --git a/apps/docs/pages/v4/docs/route.mdx b/apps/docs/pages/v4/docs/route.mdx new file mode 100644 index 00000000..44555d0a --- /dev/null +++ b/apps/docs/pages/v4/docs/route.mdx @@ -0,0 +1,16 @@ +import { Callout } from "nextra/components"; + +# Customize the admin route name + + + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + + +When setting up `next-admin` you can set the admin panel route name to whatever you want. +The admin panel route name is set by your folder name. + +Examples: + +`pages/admin/[[...nextadmin]].tsx` will be `/admin` + +`pages/prisma/[[...nextadmin]].tsx` will be `/prisma` diff --git a/apps/docs/pages/v4/docs/theming.mdx b/apps/docs/pages/v4/docs/theming.mdx new file mode 100644 index 00000000..56697423 --- /dev/null +++ b/apps/docs/pages/v4/docs/theming.mdx @@ -0,0 +1,61 @@ +import { Callout } from "nextra/components"; + +# Theming + + + This documentation covers an older version of Next Admin. If you are using the + latest version (`>=5.0.0` and above), please refer to the [current + documentation](/docs). + + +Next Admin comes with a preset that is used to theme the different components of the pages. + +You can add the preset to your Tailwind config presets : + +```js +module.exports = { + content: [ + // your own content + "./node_modules/@premieroctet/next-admin/dist/**/*.{js,ts,jsx,tsx}", + ], + presets: [require("@premieroctet/next-admin/dist/preset")], +}; +``` + +## Dark mode support + +The preset sets the `darkMode` option to `class` by default. However, if you wish to adapt to the system's preferences, you can set it to `media` in your own config. + +## Theme override + +You can override the default theme by extending the color palette in your Tailwind config. + +```typescript +module.exports = { + content: [ + // your own content + "./node_modules/@premieroctet/next-admin/dist/**/*.{js,ts,jsx,tsx}", + ], + plugins: [], + theme: { + extend: { + colors: { + nextadmin: { + background: { + default: '#FEFEFE' + } + }, + // Dark mode colors + "dark-nextadmin": { + background: { + default: "#2F2F2F" + } + } + } + } + } + presets: [require("@premieroctet/next-admin/dist/preset")], +}; +``` + +Make sure to respect the same structure as the one provided in the preset. From ce36dbb98aa7914158a5be2510854ab0acfc07a3 Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Tue, 25 Jun 2024 10:37:48 +0200 Subject: [PATCH 21/89] Add superjson doc for page router --- apps/docs/pages/docs/getting-started.mdx | 83 ++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/apps/docs/pages/docs/getting-started.mdx b/apps/docs/pages/docs/getting-started.mdx index 66ddccea..897aa599 100644 --- a/apps/docs/pages/docs/getting-started.mdx +++ b/apps/docs/pages/docs/getting-started.mdx @@ -75,7 +75,7 @@ Next-Admin uses a dynamic route `[[...nextadmin]]` to handle all the admin route - Create file : `app/api/admin/[[...nextadmin]]/page.ts` + #### Create file : `app/api/admin/[[...nextadmin]]/page.ts` ```tsx copy // app/admin/[[...nextadmin]]/page.tsx @@ -114,7 +114,7 @@ Next-Admin uses a dynamic route `[[...nextadmin]]` to handle all the admin route - Create file : `pages/api/admin/[[...nextadmin]].ts` + #### Create file : `pages/api/admin/[[...nextadmin]].ts` ```tsx copy // pages/admin/[[...nextadmin]].tsx @@ -146,10 +146,85 @@ Next-Admin uses a dynamic route `[[...nextadmin]]` to handle all the admin route }); ``` - - Do not forget to add the `options` prop to the `NextAdmin` component. + + +Do not forget to add the `options` prop to the `NextAdmin` component. + + #### SuperJson configuration + +SuperJson is required to avoid errors related to invalid serialisation properties that can occur when passing data from server to client. + +##### With Babel + + + + ```bash + yarn add -D babel-plugin-superjson-next superjson@^1 + ``` + + + + ```bash + npm install --save-dev babel-plugin-superjson-next superjson@^1 + ``` + + + ```bash + pnpm install -D babel-plugin-superjson-next superjson@^1 + ``` + + + +Add the `babel-plugin-superjson-next` plugin to your `.babelrc` file: + +```json +{ + "presets": ["next/babel"], + "plugins": ["superjson-next"] +} +``` + +##### With SWC (Experimental) + + + + ```bash +yarn add -E -D next-superjson-plugin@0.6.1 superjson + ``` + + + + ```bash +npm install --save-dev -E next-superjson-plugin@0.6.1 superjson + ``` + + + ```bash +pnpm install -E -D next-superjson-plugin@0.6.1 superjson + ``` + + + +Add the `next-superjson-plugin` plugin to your `next.config.js` file: + +```js +module.exports = { + // your current config + experimental: { + swcPlugins: [ + [ + "next-superjson-plugin", + { + excluded: [], + }, + ], + ], + }, +}; +``` + From 4249814686a21fafa0acf22183e5956e27a5ae5f Mon Sep 17 00:00:00 2001 From: Colin Regourd Date: Tue, 25 Jun 2024 15:06:31 +0200 Subject: [PATCH 22/89] Format and fix doc --- apps/docs/pages/docs/api-docs.mdx | 116 ++++++------------ apps/docs/pages/docs/code-snippets.mdx | 46 ++++++- apps/docs/pages/docs/edge-cases.mdx | 2 +- apps/docs/pages/docs/getting-started.mdx | 24 ++-- apps/docs/pages/docs/glossary.mdx | 2 +- apps/docs/pages/docs/i18n.mdx | 2 +- apps/docs/pages/docs/route.mdx | 2 +- apps/docs/pages/docs/theming.mdx | 2 +- apps/docs/pages/v4/docs/api-docs.mdx | 4 +- apps/docs/pages/v4/docs/code-snippets.mdx | 6 +- apps/docs/pages/v4/docs/edge-cases.mdx | 6 +- apps/docs/pages/v4/docs/getting-started.mdx | 4 +- apps/docs/pages/v4/docs/glossary.mdx | 4 +- apps/docs/pages/v4/docs/i18n.mdx | 4 +- apps/docs/pages/v4/docs/index.mdx | 4 +- apps/docs/pages/v4/docs/route.mdx | 4 +- apps/docs/pages/v4/docs/theming.mdx | 4 +- apps/example/options.tsx | 2 +- apps/example/pageRouterOptions.tsx | 1 - .../pagerouter/admin/[[...nextadmin]].tsx | 1 - packages/next-admin/src/appRouter.ts | 2 +- .../src/components/ActionsDropdown.tsx | 2 +- .../next-admin/src/components/Breadcrumb.tsx | 2 +- .../next-admin/src/components/MainLayout.tsx | 2 +- packages/next-admin/src/components/Menu.tsx | 4 +- packages/next-admin/src/index.ts | 1 - 26 files changed, 131 insertions(+), 122 deletions(-) diff --git a/apps/docs/pages/docs/api-docs.mdx b/apps/docs/pages/docs/api-docs.mdx index aae9d8a9..358cc0a9 100644 --- a/apps/docs/pages/docs/api-docs.mdx +++ b/apps/docs/pages/docs/api-docs.mdx @@ -1,51 +1,65 @@ -import { Tabs, Callout } from "nextra/components"; +import { Callout, Tabs } from "nextra/components"; # API - This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs/api-docs) -## Functions +## `getNextAdminProps` function The following is used only for App router. - ## `getNextAdminProps` function - `getNextAdminProps` is a function that returns the props for the [`NextAdmin`](#nextadmin--component) component. It accepts one argument which is an object with the following properties: - - `params`: the array of route params retrieved from the [optional catch-all segment](https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes#optional-catch-all-segments) - - `searchParams`: the query params [retrieved from the page](https://nextjs.org/docs/app/api-reference/file-conventions/page#searchparams-optional) - - `basePath`: the base path of your admin. It is used to build the admin URL. It is mandatory. - - `apiBasePath`: the base path of your admin API. It is used to build the admin API URL. It is mandatory. - - `schema`: the json schema generated by the `prisma generate` command - - `prisma`: your Prisma client instance - - `getMessages`: a function with no parameters that returns translation messages. It is used to translate the default messages of the library. See [i18n](/docs/i18n) for more details. - - `locale`: the locale used, find from params (e.g. `en`, `fr`) - - `options`: the [options](#next-admin-options) object - optional + | Name | Description | + | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `params` | The array of route params retrieved from the [optional catch-all segment](https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes#optional-catch-all-segments) | + | `searchParams`| The query params [retrieved from the page](https://nextjs.org/docs/app/api-reference/file-conventions/page#searchparams-optional) | + | `basePath` | The base path of your admin. It is used to build the admin URL. It is mandatory. | + | `apiBasePath` | The base path of your admin API. It is used to build the admin API URL. It is mandatory. | + | `schema` | The JSON schema generated by the `prisma generate` command. | + | `prisma` | Your Prisma client instance. | + | `getMessages` | A function with no parameters that returns translation messages. It is used to translate the default messages of the library. See [i18n](/docs/i18n) for more details. | + | `locale` | The locale used, find from params (e.g. `en`, `fr`). | + | `options` | The [options](#next-admin-options) object - optional. | + The following is used only for Page router - ## `getNextAdminProps` function `getNextAdminProps` is a function that returns the props for the [`NextAdmin`](#nextadmin--component) component. It accepts one argument which is an object with the following properties: - - `basePath`: the base path of your admin. It is used to build the admin URL. It is optional and defaults to `/admin` - - `apiBasePath`: the base path of your admin API. It is used to build the admin API URL. It is optional and defaults to `/api/admin` - - `schema`: the json schema generated by the `prisma generate` command - - `prisma`: your Prisma client instance - - `req`: the request object from the page (`IncomingMessage`) - - `getMessages`: a function with no parameters that returns translation messages. It is used to translate the default messages of the library. See [i18n](/docs/i18n) for more details. - - `locale`: the locale used, find from params (e.g. `en`, `fr`) - - `options`: the [options](#next-admin-options) object - optional + | Name | Description | + | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | `basePath` | The base path of your admin. It is used to build the admin URL. It is optional and defaults to `/admin`. | + | `apiBasePath` | The base path of your admin API. It is used to build the admin API URL. It is optional and defaults to `/api/admin`. | + | `schema` | The JSON schema generated by the `prisma generate` command. | + | `prisma` | Your Prisma client instance. | + | `req` | The request object from the page (`IncomingMessage`). | + | `getMessages` | A function with no parameters that returns translation messages. It is used to translate the default messages of the library. See [i18n](/docs/i18n) for more details. | + | `locale` | The locale used, find from params (e.g. `en`, `fr`). | + | `options` | The [options](#next-admin-options) object - optional. | +## `createHandler` function + +`createHandler` is a function that returns an object that allow you to catch all API access. It accepts an object with the following properties: + +| Name | Description | +| ----------- | -------------------------------------------------------------------------- | +| `prisma` | Your Prisma client instance. This is mandatory | +| `schema` | Your JSON Schema generated by Prisma. This is mandatory. | +| `onRequest` | A function that is executed before any request. Useful for authentication. | +| `options` | Your Next-Admin options | + + ## `` component `` is a React component that contains the entire UI of Next Admin. It can take several props from `getNextAdminProps` function, but also some additional props: @@ -366,59 +380,3 @@ The `user` property is an object that can take the following properties: | `data.name` | the name of the user displayed in the sidebar menu. This is required | | `data.picture` | the URL of the user's avatar displayed in the sidebar menu | | `logoutUrl` | an URL or path to logout the user. This is required. | - -## Api handlers - -Next-Admin export API handlers for both App Router and Page Router. - - - - - `createAppHandler` expects an object containing : - -| Name | Description | -| ----------- | -------------------------------------------------------------------------- | -| `options` | Your Next-Admin options. This is mandatory | -| `prisma` | Your Prisma client instance. This is mandatory | -| `schema` | Your JSON Schema generated by Prisma. This is mandatory. | -| `onRequest` | A function that is executed before any request. Useful for authentication. | - -It returns an object containing : - -| Name | Description | -| -------- | ---------------------------------------------------------------------------- | -| `run` | A function that executes the handler | -| `router` | The [next-connect](https://github.com/hoangvvo/next-connect) router instance | - - - - -`createApiHandler` expects an object containing : - -| Name | Description | -| ----------- | -------------------------------------------------------------------------- | -| `options` | Your Next-Admin options. This is mandatory | -| `prisma` | Your Prisma client instance. This is mandatory | -| `schema` | Your JSON Schema generated by Prisma. This is mandatory. | -| `onRequest` | A function that is executed before any request. Useful for authentication. | - -It returns an object containing : - -| Name | Description | -| -------- | ---------------------------------------------------------------------------- | -| `run` | A function that executes the handler | -| `router` | The [next-connect](https://github.com/hoangvvo/next-connect) router instance | - -When using `createApiHandler`, make sure that the api route file contains the following : - -```ts -export const config = { - api: { - bodyParser: false, - }, -}; -``` - - - - diff --git a/apps/docs/pages/docs/code-snippets.mdx b/apps/docs/pages/docs/code-snippets.mdx index d0c12300..2e032459 100644 --- a/apps/docs/pages/docs/code-snippets.mdx +++ b/apps/docs/pages/docs/code-snippets.mdx @@ -3,7 +3,7 @@ import { Callout, Tabs } from "nextra/components"; # Code snippets - This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs/code-snippets) This page contains code snippets that you can use in your projects. These are not a part of Next Admin, but may be useful for your projects. @@ -361,3 +361,47 @@ Note that you will need to use `relationOptionFormatter` instead of `optionForma With the `list` display, if the `orderField` property is defined, you will be able to apply drag and drop sorting on the categories. Upon form submission, the order will be updated accordingly, starting from 0. > The `orderField` property can also be applied for one-to-many relationships. In that case, drag and drop will also be available. + +# Custom pages + +You can create custom pages in the Next Admin UI. By reusing the `MainLayout` component, you can create a new page with the same layout as the Next Admin pages. This is an example of how to create a custom page: + +```typescript copy +// app/custom/page.tsx + +import { MainLayout } from "@premieroctet/next-admin"; +import { getMainLayoutProps } from "@premieroctet/next-admin/dist/appRouter"; +import { options } from "@/options"; +import { prisma } from "@/prisma"; + +const CustomPage = async () => { + const mainLayoutProps = getMainLayoutProps({ + basePath: "/admin", + apiBasePath: "/api/admin", + /*options*/ + }); + + return ( + + {/*Page content*/} + + ); +}; + +export default CustomPage; +``` + +Then, if you want that route to be available on the sidebar, you can add a new route - [more info](/docs/api-docs#pages): + +```typescript copy +{ + [...] + pages: { + "/custom": { + title: "Custom page", + icon: "PresentationChartBarIcon", + }, + }, + [...] +} +``` \ No newline at end of file diff --git a/apps/docs/pages/docs/edge-cases.mdx b/apps/docs/pages/docs/edge-cases.mdx index 134e46a9..51009706 100644 --- a/apps/docs/pages/docs/edge-cases.mdx +++ b/apps/docs/pages/docs/edge-cases.mdx @@ -3,7 +3,7 @@ import { Callout } from "nextra/components"; # Edge cases - This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs/edge-cases) diff --git a/apps/docs/pages/docs/getting-started.mdx b/apps/docs/pages/docs/getting-started.mdx index 897aa599..5b6b5da7 100644 --- a/apps/docs/pages/docs/getting-started.mdx +++ b/apps/docs/pages/docs/getting-started.mdx @@ -3,7 +3,7 @@ import { Callout, Steps, Tabs } from "nextra/components"; # Getting Started - This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs/getting-started) The following guide will help you get started with Next-Admin. @@ -111,6 +111,10 @@ Next-Admin uses a dynamic route `[[...nextadmin]]` to handle all the admin route + + Make sure to not use `use client` in the page. + + @@ -227,6 +231,8 @@ module.exports = { + More information about the `getNextAdminProps` [here](/docs/api-docs#getnextadminprops-function). + ### API Route `[[...nextadmin]]` @@ -252,20 +258,6 @@ Next-Admin uses a dynamic route `[[...nextadmin]]` to handle all the API routes. export { run as DELETE, run as GET, run as POST }; ``` - - - Passing the `options` prop like you'd do on Page router will result in an error in case you - have functions defined inside the options object (formatter, handlers, etc.). - Make sure to pass no `options` prop at all. - - - - - - Make sure to not use `"use client"` in the page. - - - @@ -300,6 +292,8 @@ Next-Admin uses a dynamic route `[[...nextadmin]]` to handle all the API routes. The `nextAdminRouter` function accepts a third optional parameter, which is a Next Admin [options](/docs/api-docs#next-admin-options) object. + + More information about the `createHandler` function [here](/docs/api-docs#createhandler-function). ### Next Admin options - optional diff --git a/apps/docs/pages/docs/glossary.mdx b/apps/docs/pages/docs/glossary.mdx index 14dd1d2a..58e7e959 100644 --- a/apps/docs/pages/docs/glossary.mdx +++ b/apps/docs/pages/docs/glossary.mdx @@ -1,7 +1,7 @@ import { Callout } from "nextra/components"; - This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs/glossary) In this documentation we will use the following conventions: diff --git a/apps/docs/pages/docs/i18n.mdx b/apps/docs/pages/docs/i18n.mdx index 54130589..bec35461 100644 --- a/apps/docs/pages/docs/i18n.mdx +++ b/apps/docs/pages/docs/i18n.mdx @@ -3,7 +3,7 @@ import { Callout } from "nextra/components"; # I18n - This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs/i18n) Next Admin supports i18n with the `translations` prop of the `NextAdmin` component. diff --git a/apps/docs/pages/docs/route.mdx b/apps/docs/pages/docs/route.mdx index 6f1184b6..30d4b00d 100644 --- a/apps/docs/pages/docs/route.mdx +++ b/apps/docs/pages/docs/route.mdx @@ -3,7 +3,7 @@ import { Callout } from "nextra/components"; # Customize the admin route name - This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs/route) When setting up `next-admin` you can set the admin panel route name to whatever you want. diff --git a/apps/docs/pages/docs/theming.mdx b/apps/docs/pages/docs/theming.mdx index b353580b..b2b7573c 100644 --- a/apps/docs/pages/docs/theming.mdx +++ b/apps/docs/pages/docs/theming.mdx @@ -3,7 +3,7 @@ import { Callout } from "nextra/components"; # Theming - This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs/theming) Next Admin comes with a preset that is used to theme the different components of the pages. diff --git a/apps/docs/pages/v4/docs/api-docs.mdx b/apps/docs/pages/v4/docs/api-docs.mdx index dfb684c2..0e50ec2b 100644 --- a/apps/docs/pages/v4/docs/api-docs.mdx +++ b/apps/docs/pages/v4/docs/api-docs.mdx @@ -3,7 +3,9 @@ import { Callout, Tabs } from "nextra/components"; # API - This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + This documentation covers an older version of Next Admin. If you are using the + latest version (`>=5.0.0` and above), please refer to the [current + documentation](/docs/api-docs). ## Functions diff --git a/apps/docs/pages/v4/docs/code-snippets.mdx b/apps/docs/pages/v4/docs/code-snippets.mdx index f6a2d6f3..d96fdc07 100644 --- a/apps/docs/pages/v4/docs/code-snippets.mdx +++ b/apps/docs/pages/v4/docs/code-snippets.mdx @@ -3,7 +3,9 @@ import { Callout } from "nextra/components"; # Code snippets - This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + This documentation covers an older version of Next Admin. If you are using the + latest version (`>=5.0.0` and above), please refer to the [current + documentation](/docs/code-snippets). This page contains code snippets that you can use in your projects. These are not a part of Next Admin, but may be useful for your projects. @@ -231,4 +233,4 @@ Note that you will need to use `relationOptionFormatter` instead of `optionForma With the `list` display, if the `orderField` property is defined, you will be able to apply drag and drop sorting on the categories. Upon form submission, the order will be updated accordingly, starting from 0. -> The `orderField` property can also be applied for one-to-many relationships. In that case, drag and drop will also be available. +> The `orderField` property can also be applied for one-to-many relationships. In that case, drag and drop will also be available. \ No newline at end of file diff --git a/apps/docs/pages/v4/docs/edge-cases.mdx b/apps/docs/pages/v4/docs/edge-cases.mdx index ddaa3e92..0c56e37d 100644 --- a/apps/docs/pages/v4/docs/edge-cases.mdx +++ b/apps/docs/pages/v4/docs/edge-cases.mdx @@ -2,8 +2,10 @@ import { Callout } from 'nextra/components' # Edge cases - - This documentation covers an older version of Next Admin. If you are using the latest version (`>=5.0.0` and above), please refer to the [current documentation](/docs). + + This documentation covers an older version of Next Admin. If you are using the + latest version (`>=5.0.0` and above), please refer to the [current + documentation](/docs/edge-cases). In this part, we will talk about the edge cases we found during the development of the project. Prisma allows different structures to define models and relations. diff --git a/apps/docs/pages/v4/docs/getting-started.mdx b/apps/docs/pages/v4/docs/getting-started.mdx index b91c9745..b3ccb713 100644 --- a/apps/docs/pages/v4/docs/getting-started.mdx +++ b/apps/docs/pages/v4/docs/getting-started.mdx @@ -3,7 +3,9 @@ import { Callout, Tabs } from "nextra/components"; # Getting Started - This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + This documentation covers an older version of Next Admin. If you are using the + latest version (`>=5.0.0` and above), please refer to the [current + documentation](/docs/getting-started). ## Installation diff --git a/apps/docs/pages/v4/docs/glossary.mdx b/apps/docs/pages/v4/docs/glossary.mdx index 87f7309a..f94512b9 100644 --- a/apps/docs/pages/v4/docs/glossary.mdx +++ b/apps/docs/pages/v4/docs/glossary.mdx @@ -1,7 +1,9 @@ import { Callout } from "nextra/components"; - This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + This documentation covers an older version of Next Admin. If you are using the + latest version (`>=5.0.0` and above), please refer to the [current + documentation](/docs/glossary). In this documentation we will use the following conventions: diff --git a/apps/docs/pages/v4/docs/i18n.mdx b/apps/docs/pages/v4/docs/i18n.mdx index 78b71fac..aa680b12 100644 --- a/apps/docs/pages/v4/docs/i18n.mdx +++ b/apps/docs/pages/v4/docs/i18n.mdx @@ -3,7 +3,9 @@ import { Callout } from "nextra/components"; # I18n - This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + This documentation covers an older version of Next Admin. If you are using the + latest version (`>=5.0.0` and above), please refer to the [current + documentation](/docs/i18n). Next Admin supports i18n with the `translations` prop of the `NextAdmin` component. diff --git a/apps/docs/pages/v4/docs/index.mdx b/apps/docs/pages/v4/docs/index.mdx index 9d5b7137..63672669 100644 --- a/apps/docs/pages/v4/docs/index.mdx +++ b/apps/docs/pages/v4/docs/index.mdx @@ -3,7 +3,9 @@ import { Callout } from "nextra/components"; # 🧞 Next Admin - This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + This documentation covers an older version of Next Admin. If you are using the + latest version (`>=5.0.0` and above), please refer to the [current + documentation](/docs). ###### `next-admin` is a library built on top of [Prisma](https://www.prisma.io/) and [Next.js](https://nextjs.org/) that allows you to easily manage and visualize your Prisma database in a nice GUI. diff --git a/apps/docs/pages/v4/docs/route.mdx b/apps/docs/pages/v4/docs/route.mdx index 44555d0a..1c9a227e 100644 --- a/apps/docs/pages/v4/docs/route.mdx +++ b/apps/docs/pages/v4/docs/route.mdx @@ -3,7 +3,9 @@ import { Callout } from "nextra/components"; # Customize the admin route name - This is the documentation for the latest version of Next Admin. If you are using an older version (`<5.0.0`), please refer to the [documentation](/v4/docs) + This documentation covers an older version of Next Admin. If you are using the + latest version (`>=5.0.0` and above), please refer to the [current + documentation](/docs/route). When setting up `next-admin` you can set the admin panel route name to whatever you want. diff --git a/apps/docs/pages/v4/docs/theming.mdx b/apps/docs/pages/v4/docs/theming.mdx index 56697423..d4f38ebe 100644 --- a/apps/docs/pages/v4/docs/theming.mdx +++ b/apps/docs/pages/v4/docs/theming.mdx @@ -2,10 +2,10 @@ import { Callout } from "nextra/components"; # Theming - + This documentation covers an older version of Next Admin. If you are using the latest version (`>=5.0.0` and above), please refer to the [current - documentation](/docs). + documentation](/docs/theming). Next Admin comes with a preset that is used to theme the different components of the pages. diff --git a/apps/example/options.tsx b/apps/example/options.tsx index 49c5c675..7d30fcc7 100644 --- a/apps/example/options.tsx +++ b/apps/example/options.tsx @@ -3,7 +3,7 @@ import DatePicker from "./components/DatePicker"; export const options: NextAdminOptions = { title: "⚡️ My Admin", - + model: { User: { toString: (user) => `${user.name} (${user.email})`, diff --git a/apps/example/pageRouterOptions.tsx b/apps/example/pageRouterOptions.tsx index 0c6c864b..0405a355 100644 --- a/apps/example/pageRouterOptions.tsx +++ b/apps/example/pageRouterOptions.tsx @@ -21,7 +21,6 @@ export const options: NextAdminOptions = { }, }, }, - ], fields: { role: { diff --git a/apps/example/pages/pagerouter/admin/[[...nextadmin]].tsx b/apps/example/pages/pagerouter/admin/[[...nextadmin]].tsx index 2ceaf0b6..2e99f93d 100644 --- a/apps/example/pages/pagerouter/admin/[[...nextadmin]].tsx +++ b/apps/example/pages/pagerouter/admin/[[...nextadmin]].tsx @@ -31,5 +31,4 @@ export const getServerSideProps: GetServerSideProps = async ({ req }) => schema, options: pageOptions, req, - }); diff --git a/packages/next-admin/src/appRouter.ts b/packages/next-admin/src/appRouter.ts index c78fb257..6e2e89da 100644 --- a/packages/next-admin/src/appRouter.ts +++ b/packages/next-admin/src/appRouter.ts @@ -6,4 +6,4 @@ export const getNextAdminProps = async (params: GetPropsFromParamsParams) => { return _getPropsFromParams({ ...params, isAppDir: true }); }; -export { getMainLayoutProps } from "./utils/props"; \ No newline at end of file +export { getMainLayoutProps } from "./utils/props"; diff --git a/packages/next-admin/src/components/ActionsDropdown.tsx b/packages/next-admin/src/components/ActionsDropdown.tsx index e1052a9a..fb7a51e8 100644 --- a/packages/next-admin/src/components/ActionsDropdown.tsx +++ b/packages/next-admin/src/components/ActionsDropdown.tsx @@ -41,7 +41,7 @@ const ActionsDropdown = ({