@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
JavaScript
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 };