UNPKG

@premieroctet/next-admin

Version:

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 proje

505 lines (504 loc) 21.9 kB
import external_lodash_clonedeep_default from "lodash.clonedeep"; import { ITEMS_PER_PAGE } from "../config.mjs"; import { validateQuery } from "./advancedSearch.mjs"; import { getSchema } from "./globals.mjs"; import { getDefinitionFromRef } from "./jsonSchema.mjs"; import { Prisma } from "./prisma-runtime.mjs"; import { enumValueForEnumType, findRelationInData, getModelIdProperty, getToStringForRelations, modelHasIdField, transformData } from "./server.mjs"; import { capitalize, extractSerializable, isScalar, uncapitalize } from "./tools.mjs"; const createNestedWherePredicate = ({ field, options, search, searchOptions }, acc = {})=>{ const resource = getSchema().definitions[field.type]; const resourceProperties = resource.properties; acc[field.name] = { OR: searchOptions?.map((searchOption)=>{ const [_, subFieldName, ...rest] = searchOption.toString().split("."); const subField = resourceProperties[subFieldName]; if (!subField) return null; if (subField.__nextadmin?.kind === "scalar") { if (subField.__nextadmin?.isList) { let searchTerm = search; if (subField.__nextadmin?.type !== "String" && !isNaN(Number(search))) searchTerm = Number(search); return { has: searchTerm }; } if (subField.__nextadmin?.type === "String") { const mode = Prisma?.QueryMode ? { mode: Prisma.QueryMode.insensitive } : {}; return { [subFieldName]: { contains: search, ...mode } }; } if (subField.__nextadmin?.type === "Int" && !isNaN(Number(search))) return { [subFieldName]: Number(search) }; } else if (subField.__nextadmin?.kind === "object") { const predicate = createNestedWherePredicate({ field: { ...subField.__nextadmin, name: subFieldName }, options, search, searchOptions: [ [ subFieldName, ...rest ].join(".") ] }); if (subField.__nextadmin?.isList) predicate[subFieldName] = { some: predicate[subFieldName] }; return predicate; } }).filter(Boolean) }; return acc; }; const createWherePredicate = ({ resource, options, search, otherFilters, advancedSearch })=>{ let fieldsFiltered = getFieldsFiltered(resource, options); const searchFilter = search ? { OR: fieldsFiltered?.filter(([, field])=>field?.__nextadmin?.kind === "scalar" || field?.__nextadmin?.kind === "enum" || field?.__nextadmin?.kind === "object").map(([name, field])=>{ const fieldNextAdmin = field?.__nextadmin; if (fieldNextAdmin?.kind === "object") return createNestedWherePredicate({ field: { ...fieldNextAdmin, name }, options, search, searchOptions: options?.model?.[resource]?.list?.search?.filter((searchOption)=>searchOption?.toString().startsWith(name)) }); if (fieldNextAdmin?.kind === "enum" && fieldNextAdmin?.enum) { const enumDefinition = getDefinitionFromRef(getSchema(), fieldNextAdmin.enum.$ref); const enumValueForSearchTerm = enumValueForEnumType(enumDefinition, search); if (enumValueForSearchTerm && enumDefinition?.enum) return { [name]: enumDefinition.enum.find((val)=>val === enumValueForSearchTerm) }; } if (fieldNextAdmin?.kind === "scalar" && fieldNextAdmin?.isList) { if (fieldNextAdmin?.type !== "String" && Number.isNaN(Number(search))) return null; return { [name]: { has: fieldNextAdmin?.type === "String" ? search : Number(search) } }; } if (fieldNextAdmin?.type === "String") { const mode = Prisma?.QueryMode ? { mode: Prisma.QueryMode.insensitive } : {}; return { [name]: { contains: search, ...mode } }; } if (fieldNextAdmin?.type === "Int" && !isNaN(Number(search))) return { [name]: Number(search) }; return null; }).filter(Boolean) } : {}; const externalFilters = otherFilters ?? []; const advancedSearchFilter = advancedSearch ? getWherePredicateFromQueryParams(advancedSearch) : null; return { AND: [ ...externalFilters, searchFilter, advancedSearchFilter ].filter(Boolean) }; }; const getFieldsFiltered = (resource, options)=>{ const model = getSchema().definitions[resource]; const modelProperties = model.properties; let fieldsFiltered = Object.entries(modelProperties).filter(([, field])=>field.__nextadmin?.kind === "scalar"); const list = options?.model?.[resource]?.list; if (list) fieldsFiltered = list?.search ? Object.entries(modelProperties).filter(([name])=>list.search?.some((search)=>{ const searchNameSplit = search?.toString().split("."); return searchNameSplit?.[0] === name; })) : fieldsFiltered; return fieldsFiltered; }; const getWherePredicateFromQueryParams = (query)=>validateQuery(query); const mapModelFilters = async (filters)=>{ if (!filters) return []; const newFilters = await Promise.all(filters.map(async (filter)=>{ if ("function" == typeof filter) { const asyncFilters = await filter(); return asyncFilters; } return filter; })); return newFilters.flat().filter(Boolean); }; const preparePrismaListRequest = async (resource, searchParams, options, skipFilters = false)=>{ const idProperty = getModelIdProperty(resource); const model = getSchema().definitions[resource]; const modelProperties = model.properties; const search = searchParams.get("search") || ""; const advancedSearch = searchParams.get("q") || ""; let filtersParams = []; try { filtersParams = skipFilters ? [] : JSON.parse(searchParams.get("filters")); } catch {} const page = Number(searchParams.get("page")) || 1; const itemsPerPage = Number(searchParams.get("itemsPerPage")) || options?.model?.[resource]?.list?.defaultListSize || ITEMS_PER_PAGE; const fieldSort = options?.model?.[resource]?.list?.defaultSort; const filters = await mapModelFilters(options?.model?.[resource]?.list?.filters); const fieldFilters = filters?.filter((filter)=>{ if (Array.isArray(filtersParams)) return filtersParams.includes(filter.name); return filter.active; })?.map((filter)=>filter.value); let orderBy = {}; if (options?.model?.[resource]?.list?.orderField) orderBy[options?.model?.[resource]?.list?.orderField] = "asc"; else { const createSortObject = (field, direction)=>{ const modelFieldSortParam = modelProperties[field]; const modelFieldNextAdminData = modelFieldSortParam?.__nextadmin; if (direction in Prisma.SortOrder) { if (field in Prisma[`${capitalize(resource)}ScalarFieldEnum`]) return { [field]: direction }; else if (modelFieldNextAdminData?.kind === "object") if (modelFieldNextAdminData.isList) return { [field]: { _count: direction } }; else { const modelFieldSortProperty = options?.model?.[resource]?.list?.fields?.[field]?.sortBy; const resourceSortByField = modelFieldSortProperty ?? getModelIdProperty(modelFieldNextAdminData.type); return { [field]: { [resourceSortByField]: direction } }; } } return null; }; const sortParam = searchParams.get("sortColumn"); const sortDirection = searchParams.get("sortDirection"); if (sortParam && sortDirection) { const sortObject = createSortObject(sortParam, sortDirection); if (sortObject) orderBy = sortObject; } else if (fieldSort) if (Array.isArray(fieldSort)) { const sortObjects = fieldSort.map((sort)=>createSortObject(sort.field, sort.direction || "asc")).filter(Boolean); if (sortObjects.length > 0) orderBy = sortObjects; } else { const sortObject = createSortObject(fieldSort.field, fieldSort.direction || "asc"); if (sortObject) orderBy = sortObject; } } let select; let where = {}; const list = options?.model?.[resource]?.list; select = selectPayloadForModel(resource, list, "object"); where = createWherePredicate({ resource, options, search, otherFilters: [ ...fieldFilters ?? [], ...list?.where ?? [] ], advancedSearch }); if (0 === Object.keys(orderBy).length) orderBy = { [idProperty]: "asc" }; return { select, where, orderBy: orderBy, skip: (page - 1) * itemsPerPage, take: itemsPerPage }; }; const optionsFromResource = async ({ originResource, property, ...args })=>{ const relationshipField = args.options?.model?.[originResource]?.edit?.fields?.[property]?.relationshipSearchField; if (relationshipField) { const targetModel = getSchema().definitions[args.resource]; if (!targetModel) throw new Error(`Model ${args.resource} not found in schema`); const targetModelProperties = targetModel.properties; const modelField = targetModelProperties[relationshipField]; if (modelField && modelField.__nextadmin?.type !== "scalar") args.resource = modelField.__nextadmin?.type; else console.warn("Used relationshipSearch on a scalar field, ignoring property"); } const data = await fetchDataList(args, true); const { data: dataItems, total, error } = data; const { resource } = args; const idProperty = getModelIdProperty(resource); const dataTableItems = mapDataList({ ...args, fetchData: external_lodash_clonedeep_default(dataItems) }); const toStringModel = getToStringForRelations(originResource, property, args.resource, args.options); const displayMode = args.options?.model?.[originResource]?.edit?.fields?.[property]?.display; return { data: dataItems.map((item)=>{ const dataTableItem = dataTableItems.find((dataTableItem)=>dataTableItem[idProperty].value === item[idProperty]); return { label: toStringModel(item), value: item[idProperty], data: "table" === displayMode ? dataTableItem : void 0 }; }), total, error }; }; const fetchDataList = async ({ prisma, resource, options, searchParams }, skipFilters = false)=>{ const prismaListRequest = await preparePrismaListRequest(resource, searchParams, options, skipFilters); let data = []; let total; let error = null; const { where, orderBy, skip, take, select } = prismaListRequest; const idProperty = getModelIdProperty(resource); try { const resourceIds = await prisma[uncapitalize(resource)].findMany({ select: { [idProperty]: true }, where, orderBy, skip, take }); data = await prisma[uncapitalize(resource)].findMany({ select, where: { [idProperty]: { in: resourceIds.map((item)=>item[idProperty]) } }, orderBy }); total = await prisma[uncapitalize(resource)].count({ where }); } catch (e) { const { skip, take, orderBy } = prismaListRequest; data = await prisma[uncapitalize(resource)].findMany({ skip, take, orderBy }); total = await prisma[uncapitalize(resource)].count(); error = e.message ? e.message : e; console.error(e); } return { data, total, error }; }; const mapDataList = ({ context, appDir, fetchData, ...args })=>{ const { resource, options } = args; const originalFetchData = external_lodash_clonedeep_default(fetchData); const data = findRelationInData(fetchData, getSchema().definitions[resource]); const listFields = options?.model?.[resource]?.list?.fields ?? {}; const listDisplayOptions = options?.model?.[resource]?.list?.display ?? []; const originalData = external_lodash_clonedeep_default(data); data.forEach((item, index)=>{ context.row = originalData[index]; if ("_count" in item && "object" == typeof item._count) Object.keys(item._count).forEach((key)=>{ item[key] = { type: "count", value: item._count[key], __nextadmin_formatted: item._count[key].toString() }; }); delete item._count; Object.keys(item).forEach((key)=>{ let itemValue = null; const model = capitalize(key); const idProperty = getModelIdProperty(model); if ("object" == typeof item[key] && null !== item[key]) { switch(item[key].type){ case "link": itemValue = item[key].value.label; break; case "count": itemValue = item[key].value; break; case "date": itemValue = item[key].value.toString(); break; default: itemValue = item[key][idProperty]; break; } item[key].__nextadmin_formatted = itemValue; } else if (isScalar(item[key]) && null !== item[key]) { item[key] = { type: "scalar", value: item[key], __nextadmin_formatted: item[key].toString() }; itemValue = item[key].value; } if (appDir && key in listFields && listFields[key]?.formatter && null !== itemValue) item[key].__nextadmin_formatted = listFields[key]?.formatter?.(itemValue ?? item[key], context); else { if ("object" == typeof item[key]?.__nextadmin_formatted) item[key].__nextadmin_formatted = item[key].__nextadmin_formatted[idProperty]; data[index][key] = item[key]; } if ("object" == typeof item[key]?.value) item[key].value.label = item[key].value.label[idProperty]; }); listDisplayOptions.forEach((displayOpt)=>{ if ("object" == typeof displayOpt) { const { key, formatter } = displayOpt; const virtualFieldCtx = { ...context, row: originalFetchData[index] }; const formatted = formatter?.(virtualFieldCtx); item[key] = { type: "string" == typeof formatted ? displayOpt.type ?? "scalar" : displayOpt.type, value: "link" === displayOpt.type ? { url: displayOpt.url(virtualFieldCtx) } : "string" == typeof formatted ? formatted : void 0, __nextadmin_formatted: appDir || "string" != typeof formatted ? formatted : null, isOverridden: "link" === displayOpt.type ? true : null }; } }); }); return data; }; const getMappedDataList = async ({ context, appDir = false, ...args })=>{ const { data: fetchData, total, error } = await fetchDataList(args); const rawData = external_lodash_clonedeep_default(fetchData); return { data: mapDataList({ context, appDir, fetchData, ...args }), total, error, rawData: extractSerializable(rawData) }; }; const isVirtualField = (displayOpts)=>"object" == typeof displayOpts && "dependsOn" in displayOpts; const selectPayloadForModel = (resource, options, level = "scalar")=>{ const model = getSchema().definitions[resource]; const properties = model.properties; const idProperty = getModelIdProperty(resource); const displayKeys = options?.display; const orderField = options?.orderField; const defaultSelectedKeysForVirtualFields = options?.display?.filter(isVirtualField).flatMap((disp)=>disp?.dependsOn)?.reduce((acc, val)=>{ if (val) acc[val] = true; return acc; }, {}) ?? {}; let selectedFields = Object.entries(properties).reduce((acc, [name, field])=>{ const fieldNextAdmin = field.__nextadmin; if (orderField === name) acc[name] = true; if (displayKeys && displayKeys.includes(name) || !displayKeys) if (fieldNextAdmin?.kind === "object" && "object" === level) { if (fieldNextAdmin?.isList) { const countFields = acc["_count"]?.select ?? {}; acc["_count"] = { select: { ...countFields, [name]: true } }; } acc[name] = { select: selectPayloadForModel(fieldNextAdmin.type, {}, "scalar") }; const orderField = options?.fields?.[name]?.orderField || options?.orderField; if (orderField) acc[name].orderBy = { [orderField]: "asc" }; } else acc[name] = true; return acc; }, modelHasIdField(resource) ? { [idProperty]: true, ...defaultSelectedKeysForVirtualFields } : {}); return selectedFields; }; const getDataItem = async ({ prisma, resource, options, resourceId, locale, isAppDir })=>{ const edit = options?.model?.[resource]?.edit; const idProperty = getModelIdProperty(resource); const select = selectPayloadForModel(resource, edit, "object"); const schemaResourceProperties = getSchema().definitions[resource].properties; Object.entries(select).forEach(([key, value])=>{ const fieldType = schemaResourceProperties[key]?.__nextadmin?.type; if (fieldType) { const relatedResourceOptions = options?.model?.[fieldType]?.list; if (edit?.fields?.[key]?.display === "table") { if (!relatedResourceOptions?.display) throw new Error(`'table' display mode set for field '${key}', but no list display is setup for model ${fieldType}`); select[key] = { select: selectPayloadForModel(fieldType, relatedResourceOptions, "object") }; } } }); let data = await prisma[resource].findUniqueOrThrow({ select, where: { [idProperty]: resourceId } }); const relationshipsRawData = {}; Object.entries(data).forEach(([key, value])=>{ if (Array.isArray(value)) { const fieldType = schemaResourceProperties[key]?.__nextadmin?.type; relationshipsRawData[key] = external_lodash_clonedeep_default(value); if (fieldType) { if (edit?.fields?.[key]?.display === "table") data[key] = mapDataList({ context: { locale }, appDir: isAppDir, fetchData: value, options, resource: fieldType }); } } }); data = await transformData(data, resource, edit ?? {}, options); return { data, relationshipsRawData }; }; const includeDataByDepth = (modelProperties, currentDepth, maxDepth)=>{ const include = Object.entries(modelProperties)?.reduce((acc, [name, field])=>{ if (field.__nextadmin?.kind === "object") if (currentDepth < maxDepth - 1) { const nextModel = getSchema().definitions[field.__nextadmin.type].properties; acc[name] = { include: includeDataByDepth(nextModel, currentDepth + 1, maxDepth) }; } else acc[name] = true; return acc; }, {}); return include; }; const getRawData = async ({ prisma, resource, resourceIds, maxDepth = 2 })=>{ const model = getSchema().definitions[resource]; const modelProperties = model.properties; const include = includeDataByDepth(modelProperties, 1, maxDepth); const data = await prisma[resource].findMany({ where: { id: { in: resourceIds } }, include }); return data; }; export { createWherePredicate, getDataItem, getMappedDataList, getRawData, mapDataList, mapModelFilters, optionsFromResource, selectPayloadForModel };