diff --git a/.changeset/sweet-ads-drum.md b/.changeset/sweet-ads-drum.md new file mode 100644 index 00000000..1489107b --- /dev/null +++ b/.changeset/sweet-ads-drum.md @@ -0,0 +1,5 @@ +--- +"@premieroctet/next-admin": minor +--- + +feat: add dynamic filters (#491) diff --git a/apps/docs/pages/docs/api/model-configuration.mdx b/apps/docs/pages/docs/api/model-configuration.mdx index 3aac70b3..25a62741 100644 --- a/apps/docs/pages/docs/api/model-configuration.mdx +++ b/apps/docs/pages/docs/api/model-configuration.mdx @@ -277,9 +277,17 @@ The `filters` property allow you to define a set of Prisma filters that user can ), }, + { + name: "group", + type: "String", + description: + "an id that will be used to give filters with the same group name a radio like behavior", + }, ]} /> +It can also be an async function that returns the above type so that you can have a dynamic list of filters. + #### `list.exports` property The `exports` property is available in the `list` property. It's an object or an array of objects that can have the following properties: diff --git a/apps/example/options.tsx b/apps/example/options.tsx index c00cbf70..70dd332a 100644 --- a/apps/example/options.tsx +++ b/apps/example/options.tsx @@ -252,6 +252,19 @@ export const options: NextAdminOptions = { published: false, }, }, + async function byCategoryFilters() { + const categories = await prisma.category.findMany({ + select: { id: true, name: true }, + take: 5, + }); + + return categories.map((category) => ({ + name: category.name, + value: { categories: { some: { id: category.id } } }, + active: false, + group: "by_category_id", + })); + }, ], search: ["title", "content", "tags", "author.name"], fields: { diff --git a/packages/next-admin/src/components/Badge.tsx b/packages/next-admin/src/components/Badge.tsx index 2bce54fb..5ed06364 100644 --- a/packages/next-admin/src/components/Badge.tsx +++ b/packages/next-admin/src/components/Badge.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import Checkbox from "./radix/Checkbox"; type BadgeProps = { @@ -15,6 +15,10 @@ const Badge = ({ isActive, onClick, ...props }: BadgeProps) => { onClick?.(e); }; + useEffect(() => { + setActive(isActive); + }, [isActive]); + return (
({ }, [query?.filters]); const toggleFilter = (name: string) => { + const toggledFilter = filters?.find((filter) => filter.name === name); + + if (!toggledFilter) return; + + const newFiltersNames = currentFilters + ?.map((filter) => { + let isActive = filter.active; + + if (filter.name === name) { + isActive = !filter.active; + } + + if ( + filter.name !== toggledFilter.name && + filter.group === toggledFilter.group + ) { + isActive = false; + } + + return { + ...filter, + active: isActive, + }; + }) + .filter((filter) => filter.active) + .map((filter) => filter.name); + router?.push({ pathname: location.pathname, query: { ...query, page: 1, - filters: JSON.stringify( - currentFilters - ?.map((filter) => ({ - ...filter, - active: filter.name === name ? !filter.active : filter.active, - })) - .filter((filter) => filter.active) - .map((filter) => filter.name) - ), + filters: JSON.stringify(newFiltersNames), }, }); }; diff --git a/packages/next-admin/src/components/List.tsx b/packages/next-admin/src/components/List.tsx index c4f3cc58..87e24738 100644 --- a/packages/next-admin/src/components/List.tsx +++ b/packages/next-admin/src/components/List.tsx @@ -14,6 +14,7 @@ import { useDeleteAction } from "../hooks/useDeleteAction"; import { useRouterInternal } from "../hooks/useRouterInternal"; import { AdminComponentProps, + FilterWrapper, ListData, ListDataItem, ModelIcon, @@ -91,7 +92,8 @@ function List({ const hasDeletePermission = !modelOptions?.permissions || modelOptions?.permissions?.includes("delete"); - const filterOptions = modelOptions?.list?.filters; + const filterOptions = modelOptions?.list + ?.filters as FilterWrapper[]; if ( !(modelOptions?.list?.search && modelOptions?.list?.search?.length === 0) ) { @@ -271,9 +273,9 @@ function List({ }} > - - {pageSize} - + + {pageSize} + 10 diff --git a/packages/next-admin/src/types.ts b/packages/next-admin/src/types.ts index d0c6f3c4..1daa3e18 100644 --- a/packages/next-admin/src/types.ts +++ b/packages/next-admin/src/types.ts @@ -205,6 +205,10 @@ export type FilterWrapper = { * @link https://www.prisma.io/docs/orm/reference/prisma-client-reference#filter-conditions-and-operators */ value: Filter; + /** + * An id that will be used to give filters with the same group name a radio like behavior + */ + group?: string; }; type RelationshipSearch = { @@ -428,7 +432,7 @@ export type ListOptions = { /** * define a set of Prisma filters that user can choose in list */ - filters?: FilterWrapper[]; + filters?: Array | (() => Promise[]>)>; /** * define a set of Prisma filters that are always active in list */ diff --git a/packages/next-admin/src/utils/prisma.ts b/packages/next-admin/src/utils/prisma.ts index 47ec1a15..6f04f6d3 100644 --- a/packages/next-admin/src/utils/prisma.ts +++ b/packages/next-admin/src/utils/prisma.ts @@ -7,6 +7,7 @@ import { Enumeration, Field, Filter, + FilterWrapper, ListOptions, Model, ModelName, @@ -251,12 +252,34 @@ const getWherePredicateFromQueryParams = (query: string) => { return validateQuery(query); }; -const preparePrismaListRequest = ( +export const mapModelFilters = async ( + filters: ListOptions["filters"] +): Promise[]> => { + if (!filters) { + return []; + } + + const newFilters = await Promise.all( + filters.map(async (filter) => { + if (typeof filter === "function") { + const asyncFilters = await filter(); + + return asyncFilters; + } + + return filter; + }) + ); + + return newFilters.flat().filter(Boolean); +}; + +const preparePrismaListRequest = async ( resource: M, searchParams: any, options?: NextAdminOptions, skipFilters: boolean = false -): PrismaListRequest => { +): Promise> => { const model = globalSchema.definitions[ resource ] as SchemaDefinitions[ModelName]; @@ -275,7 +298,11 @@ const preparePrismaListRequest = ( const fieldSort = options?.model?.[resource]?.list?.defaultSort; - const fieldFilters = options?.model?.[resource]?.list?.filters + const filters = await mapModelFilters( + options?.model?.[resource]?.list?.filters + ); + + const fieldFilters = filters ?.filter((filter) => { if (Array.isArray(filtersParams)) { return filtersParams.includes(filter.name); @@ -331,10 +358,7 @@ const preparePrismaListRequest = ( resource, options, search, - otherFilters: [ - ...fieldFilters ?? [], - ...list?.where ?? [] - ], + otherFilters: [...(fieldFilters ?? []), ...(list?.where ?? [])], advancedSearch, }); @@ -443,7 +467,7 @@ const fetchDataList = async ( { prisma, resource, options, searchParams }: FetchDataListParams, skipFilters: boolean = false ) => { - const prismaListRequest = preparePrismaListRequest( + const prismaListRequest = await preparePrismaListRequest( resource, searchParams, options, @@ -674,6 +698,7 @@ export const getDataItem = async ({ select, where: { [idProperty]: resourceId }, }); + Object.entries(data).forEach(([key, value]) => { if (Array.isArray(value)) { const fieldType = diff --git a/packages/next-admin/src/utils/props.ts b/packages/next-admin/src/utils/props.ts index 02d29f48..fd8cd69d 100644 --- a/packages/next-admin/src/utils/props.ts +++ b/packages/next-admin/src/utils/props.ts @@ -12,7 +12,7 @@ import { NextAdminOptions, } from "../types"; import { getCustomInputs } from "./options"; -import { getDataItem, getMappedDataList } from "./prisma"; +import { getDataItem, getMappedDataList, mapModelFilters } from "./prisma"; import { applyVisiblePropertiesInSchema, getEnableToExecuteActions, @@ -130,6 +130,13 @@ export async function getPropsFromParams({ appDir: isAppDir, }); + if (options?.model?.[resource]?.list?.filters) { + // @ts-expect-error + clientOptions.model[resource].list.filters = await mapModelFilters( + options.model![resource]!.list!.filters + ); + } + const dataIds = data.map( (item) => item[getModelIdProperty(resource)].value );