mongoose-explore
an opinionated and highly customizable admin interface interface generated using pug to explore your mongoose models. strongly inspired by adminjs
why not adminjs?
- you don't want a full-blown admin interface. just a simple database explorer to view and manipulate your data
adminjs is esm only i've seen the light. starting v5 mongoose explore now only supports esm- all operations trigger your schema's validations and hooks, if any
installation
using npm
npm install mongoose-paginate-v2 mongoose-explore
using yarn
yarn add mongoose-paginate-v2 mongoose-explore
using pnpm
pnpm add mongoose-paginate-v2 mongoose-explore
prerequisites
- configure
mongoose-paginate-v2
plugin on your models - do not disable mongoose's schema types casting. specifically for
String
, Number
, Boolean
, and Date
as internally Model.castObject()
is used to accurately convert url-encoded data to align with the corresponding model's schema when creating and editing documents - if your application utilizes
helmet
middleware, allow the unsafe-inline
script source directive in your content security policy, as follows:
app.use(helmet({
contentSecurityPolicy: {
directives: {
"script-src": ["'unsafe-inline'", "https://cdn.jsdelivr.net"]
}
}
}));
simple usage
import express from "express";
import mongoose from "mongoose";
import { MongooseExplorer } from "mongoose-explore";
import paginate from "mongoose-paginate-v2";
mongoose.plugin(paginate);
const app = express();
const explorer = new MongooseExplorer({ mongoose });
app.use(explorer.rootpath, explorer.router());
await mongoose.connect(url);
app.listen(port);
voila! you now have an admin interface interface on /admin/explorer to explore and manipulate your mongoose models
configuration
all configuration options
interface MongooseExplorerOptions {
mongoose: typeof mongoose;
rootpath?: string;
datetimeformatter?: (date: Date) => string;
explorables?: string[];
query_runner?: boolean | string[];
fallback_value?: string;
version_key?: string;
show_indexes?: string;
timestamps?: {
created?: string;
updated?: string;
};
widgets?: Array<
| {
type: "stat";
title: string;
resolver: () => Promise<string | number>;
render?: (value: string | number) => string;
}
| {
type: "tabular";
title: string;
resolver: () => Promise<Record<string, any>>;
header?: boolean;
}
| {
type: "doughnut chart";
title: string;
resolver: () => Promise<{
labels: string[];
dataset: Array<{ data: number[]; label?: string }>;
}>;
}
| {
type: "pie chart";
title: string;
resolver: () => Promise<{
labels: string[];
dataset: Array<{ label?: string; data: number[] }>;
}>;
}
| {
type: "line chart";
title: string;
resolver: () => Promise<{
labels: string[];
dataset: Array<{
label: string;
data: number[];
}>;
}>;
}
| {
type: "radar chart";
title: string;
resolver: () => Promise<{
labels: string;
dataset: Array<{ label: string; data: number[] }>;
}>;
}
| {
type: "bar chart";
title: string;
resolver: () => Promise<{
labels: string[];
dataset: Array<{ label: string; data: number[] }>;
}>;
}
| {
type: "custom";
title: string;
render: (ds: DesignSystem) => string | Promise<string>;
wrapper_style?: string;
}
>;
resources: Record<string, {
explorable?: string;
creatable?: boolean;
deletable?: boolean | ((doc: mongoose.LeanDocument) => boolean);
editable?: boolean | ((doc: mongoose.LeanDocument) => boolean);
sortable?: boolean;
limit?: number;
properties?: Record<string, {
label?: string;
editable?: boolean;
sortable?: boolean;
viewable?: boolean;
filterable?: boolean;
required?: boolean | ((value?: any) => boolean);
creatable?: boolean;
textarea?: boolean;
as_image?: boolean;
renderers?: {
list?: (value: any) => string;
create?: (enumvalues: any[] | null) => string;
filter?: (enumvalues: any[] | null) => string;
view?: (value: any) => string;
edit?: (params: { value: any; enumvalues: any[] | null }) => string;
}
}>;
viewables?: string[];
filterables?: string[];
creatables?: string[];
editables?: string[];
sortables?: string[];
ref_newtab?: boolean;
virtuals?: Record<string, (doc: mongoose.LeanDocument) => string>;
show_indexes?: boolean;
query_runner?: boolean;
bulk_delete?: {
enabled: boolean;
use_document?: boolean;
};
actions?: {
customs?: {
bulk?: Array<{
operation: string;
handler: (docs: string[] | mongoose.Document[]) => void | Promise<void>;
as_documents?: boolean;
guard?: boolean | string;
element?: {
variant?: "primary" | "secondary" | "outline" | "danger" | "success" | "warning";
label?: string;
style?: string;
wrapper_style?: string;
};
}>;
document?: Array<{
operation: string;
handler: (doc: mongoose.Document) => void | Promise<void>;
applicable?: (doc: mongoose.Document) => boolean;
guard?: boolean | string;
element?: {
variant?: "primary" | "secondary" | "outline" | "danger" | "success" | "warning";
label?: string;
style?: string;
wrapper_style?: string;
};
}>;
};
create?: {
handler?: (body: Record<string, any>) => Promise<mongoose.Document>;
pre?: (body: Record<string, any>) => void | Promise<void>;
post?: (doc: mongoose.Document) => void | Promise<void>;
};
delete?: {
handler?: (doc: mongoose.Document) => Promise<mongoose.Document>;
post?: (doc: mongoose.Document) => void | Promise<void>;
};
update?: {
handler?: (doc: mongoose.Document,body: Record<string, any>) => Promise<mongoose.Document>;
pre?: (doc_id: string, body: Record<string, any>) => void | Promise<void>;
post?: (doc: mongoose.Document, body: Record<string, any>) => void | Promise<void>;
};
}
cascade_delete?:
| {
model: mongoose.Model<any>;
relation: {
local_field: string;
foreign_field: string;
}
}[]
| ((doc: any, session?: mongoose.mongo.ClientSession) => Promise<void>);
}>;
views?: Array<{
name: string;
execute: (t: { limit: number; page?: number }) => Promsie<{
docs: Record<string, any>;
total_docs?: number;
total_pages?: number;
page?: number;
prev_ppage?: number | null;
next_page?: number | null;
}>;
newtab?: boolean;
limit?: number;
render?: string | ((t: { data: any[]; ds: DesignSystem }) => string);
design_system?: boolean;
}>;
pages?: Array<{
path: string;
pug: string;
label?: string;
newtab?: boolean;
design_system?: boolean;
locals?: (req: express.Request) => Record<string, any> | Promise<Record<string, any>>;
}>;
titles?: {
home?: string;
list?: (modelname: string) => string;
view?: (modelname: string, doc: mongoose.LeanDocument) => string;
edit?: (modelname: string, doc: mongoose.LeanDocument) => string;
create?: (modelname: string) => string;
queryrunner?: (modelname: string) => string;
}
theming?: {
colors?: {
primary?: string;
secondary?: string;
danger?: string;
warning?: string;
success?: string;
border?: string;
text?: string;
};
font?: {
url: string;
family: string;
};
};
}
consider this model
mongoose.model(
"User",
new mongoose.Schema({
email: {
type: String,
required: true
},
password: {
type: String,
required: true
},
profile: {
name: String,
settings: {
notifications: Boolean
}
},
role: {
type: String,
enum: ["default", "admin"],
default: "default"
},
is_banned: {
type: Boolean,
default: false
},
created_at: {
type: Date,
default: () => new Date()
},
updated_at: {
type: Date,
default: () => new Date()
}
})
);
mongoose.model("Post", new mongoose.Schema({
content: String,
author_id: {
type: mongoose.Schema.Types.ObjectId,
ref: "User"
}
}));
mongoose.model("Notification", new mongoose.Schema({
recipient_id: {
type: mongoose.Schema.Types.ObjectId,
ref: "User"
}
}));
rootpath
changes the default rootpath /admin/explorer where the interface is mounted
new MongooseExplorer({ mongoose, rootpath: "/some-other-path" });
datetimeformatter
formatted string representation of dates
new MongooseExplorer({
mongoose,
datetimeformatter: (date) => datefns.format(date, "MM/dd/yyyy")
});
defaults to
date.toLocaleString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: true,
day: "2-digit",
month: "long",
year: "numeric"
});
explorables
defines which resources should be explorable. if set, only the resources included in it are explorable, rendering any explorable: true
setting under individual resource configuration irrelevant
new MongooseExplorer({
mongoose,
explorables: ["User"]
});
query_runner
defines whether query runner is enabled for specific resources or for all resources. set to true
to enable for all resources, or provide an array of string resource names to enable it selectively for the specified resources, rendering individual query_runner
configurations for each resource irrelevant
new MongooseExplorer({
mongoose,
query_runner: ["User"]
});
fallback_value
html or string to be rendered if a property has no value. defaults to -
new MongooseExplorer({
mongoose,
fallback_value: "<span style='font-style: italic'>???</span>"
});
version_key
the version key of your schema, if enabled. defaults to __v
show_indexes
determines whether indexes for all resources should be shown or not
timestamps
prevents these fields from being modified among other things
new MongooseExplorer({
mongoose,
timestamps: {
created: "created_at",
updated: "updated_at"
}
});
widgets
new MongooseExplorer({
mongoose,
widgets: [
{
type: "stat",
title: "Total Users",
resolver: () => Promise.resolve(20000),
render: (value) => `<p style="font-style: italic">${value.toLocaleString()}</p>`
},
...
]
});
{
type: "tabular",
title: "Recent Orders",
resolver: () => Promise.resolve([
{
customer: "john",
product: "laptop",
quantity: 2,
date: "2024-03-05 10:30",
status: "shipped"
},
{
customer: "mary",
product: "smartphone",
quantity: 1,
date: "2024-03-04 15:45",
status: "delivered"
},
...
]),
header: true
}
{
type: "bar chart",
title: "Employee Performance",
resolver: () => Promise.resolve({
labels: ["john", "sarah", "michael", "emily", "james"],
dataset: [
{
label: "quarter 1",
data: [85, 92, 78, 88, 95]
},
{
label: "quarter 2",
data: [78, 85, 90, 75, 88]
},
{
label: "quarter 3",
data: [92, 88, 95, 82, 90]
}
]
})
}
{
type: "pie chart",
title: "Expenses",
resolver: () => Promise.resolve({
labels: ["rent", "utilities", "groceries", "entertainment", "others"],
dataset: [{
data: [800, 150, 200, 100, 50]
}]
})
}
{
type: "doughnut chart",
title: "Project Tasks Distribution",
resolver: () => Promise.resolve({
labels: ["design", "development", "testing", "documentation"],
dataset: [{
data: [25, 40, 20, 15]
}]
})
}
{
type: "line chart",
title: "Project Progress",
resolver: () => Promise.resolve({
labels: ["week 1", "week 2", "week 3", "week 4", "week 5"],
dataset: [
{
label: "development progress",
data: [20, 40, 60, 80, 100]
},
{
label: "degradation progress",
data: [100, 80, 60, 40, 20]
}
]
})
}
{
type: "radar chart",
title: "Skills",
resolver: () => Promise.resolve({
labels: [
"coding",
"design",
"communication",
"problem solving",
"time management",
"teamwork"
],
dataset: [
{
label: "team A",
data: [80, 70, 85, 90, 75, 65]
},
{
label: "team B",
data: [90, 65, 80, 85, 70]
}
]
})
}
see custom pages section for more details on ds
(design system)
{
type: "custom",
title: "Custom Widget",
render: (ds) => `<div>${ds.components.button({ label: "click", variant: "primary" })}</div>`,
wrapper_style: "width: 250px; height: 250px"
}
resources
new MongooseExplorer({
mongoose,
resources: {
User: {
explorable: true,
creatable: true,
deletable: (user) => user.role !== "admin",
editable: true,
sortable: true,
limit: 10,
viewables: ["_id", "email", "profile.name"],
filterables: ["_id", "email", "created_at", "updated_at"],
creatables: ["email", "password", "profile.name"],
editables: ["profile.name"],
sortables: ["created_at"],
ref_newtab: false,
virtuals: {
another: (user) => "i will be rendered under 'another' property",
field: (user) => "<p style='font-style: italic'>so will i, under 'field' property</p>"
},
show_indexes: true,
query_runner: true,
bulk_delete: {
enabled: true,
use_document: true
},
properties: {
password: {
label: "secret",
editable: true,
sortable: true,
viewable: true,
filerable: true,
required: true,
creatable: true,
textarea: false,
as_image: true,
renderers: {
list: (value) => `${value}`,
create: (enumvalues) => `<input type="password" name="password" />`,
filter: (enumvalues) => `<input type="text" name="password" />`,
view: (value) => `<p>${value}</p>`,
edit: ({ value, enumvalues }) => `<input type="password" name="password" />`
}
},
"profile.settings.notifications": {}
},
actions: {
customs: {
bulk: [{
operation: "ban-all",
handler: (docs) => {},
as_documents: true,
guard: "are you sure you want to ban these users?",
element: {
variant: "danger",
label: "ban all",
style: "color: red; font-size: 12px",
wrapper_style: "border: 1px solid gold"
}
}],
document: [{
operation: "ban-user",
handler: async (user) => {
user.is_banned = true;
await user.save();
},
applicable: (user) => user.role !== "admin",
guard: "are you sure you want to ban this user?",
element: {
variant: "danger",
label: "ban",
style: "color: red; font-size: 12px",
wrapper_style: "border: 1px solid gold"
}
}]
},
create: {
handler: (body: req.body) => Promise<mongoose.Document>,
pre: (body: req.body) => void | Promise<void>,
post: (user: mongoose.Document) => void | Promise<void>
},
delete: {
handler: (user: mongoose.Document) => Promise<mongoose.Document>,
post: (user: mongoose.Document) => void | Promise<void>
},
update: {
handler: (user: mongoose.Document, body: req.body) => Promise<mongoose.Document>,
pre: (doc_id: string, body: req.body) => void | Promise<void>,
post: (user: mongoose.Document, body: req.body) => void | Promise<void>
}
},
cascade_delete: [
{
model: Post,
relation: {
foreign_field: "author_id",
local_field: "_id"
}
},
{
model: Notification,
relation: {
foreign_field: "recipient_id",
local_field: "_id"
}
}
]
}
}
});
all able
's are true
by default
views
configuration for dynamic data views in the UI. allows you to define custom queries which dynamically fetch and provide the data for specific sections in the UI. the concept is similar to PostgreSQL views where you dont have to type the query each time you need it
new MongooseExplorer({
mongoose,
views: [{
name: "new users",
execute: async ({ limit, page = 1 }) => {
const thirtydays = new Date();
thirtydays.setDate(thirtydays.getDate() - 30);
const users = await User.aggregate([
{
$match: {
role: "default",
created_at: {
$gte: thirtydays
}
}
},
{
$limit: limit
},
{
$addFields: {
a_new_property: "your value here"
}
}
]);
return { docs: users };
},
newtab: true,
limit: 20,
title: "New Users",
render: path.join(process.cwd(), "assets", "views", "new-users.pug"),
design_system: true,
properties: {
a_new_property: {
render: (value) => `<span></span>`;
}
}
}]
});
titles
new MongooseExplorer({
mongoose,
titles: {
home: "explorer",
list: (modelname) => `${modelname} model`,
view: (modelname, doc) => `view ${modelname} document - ${doc._id}`,
edit: (modelname, doc) => `edit ${modelname} document - ${doc._id}`,
create: (modelname) => `create ${modelname} document`,
queryrunner: (modelname) => `${modelname} query runner`
}
});
pages
new MongooseExplorer({
mongoose,
pages: [
{
path: "custom",
pug: path.join(process.cwd(), "views", "custom.pug"),
label: "Custom",
newtab: false,
* contentdiv: ""
* },
* components: {
* title: (t: { label: string; style?: string; }) => <h2>,
* link: (t: { label: string; href: string; newtab?: boolean; style?: string; }) => <a>,
* dash: (t?: { style?: string; }) => <hr>,
* button: (t: { label: string; variant?: Variant; style?: string }) => <button>
* }
* }
* ```
*/
design_system: true,
/**
* provide local variables for your custom page. the returned
* object will be merged with the below default locals object
*
* ```
* const locals = {
*
* buidlink: (path: string) => string,
* rootpath: "",
* ds: "**see above**"
* }
* ```
*/
locals: (req) => ({ user: req.user })
}
]
});
theming
self-explanatory
new MongooseExplorer({
mongoose,
theming: {
colors: {
primary: "gold",
secondary: "purple",
danger: "red",
success: "green",
warning: "yellow",
border: "gray",
text: "black"
},
font: {
url: "google font url here",
family: "font family here"
}
}
});
defaults to
{
colors: {
primary: "#4f46e5",
secondary: "#DCE546",
danger: "#dc2626",
success: "#059669",
warning: "#d97706",
border: "#4B5563",
text: "#fff",
},
font: {
url: "https://fonts.googleapis.com/css2?family=Finlandica&display=swap",
family: `"Finlandica", sans-serif;`
}
}
[!NOTE]
background-color
is set to #101113
, which is a very dark shade and is not customizable. if you choose to customize the colors, be mindful of the background color as it sets the foundation for the overall appearance of the interface
important notes
- string filtering supports regex patterns enclosed within
/
characters. simply place your regex pattern between these delimeters. for example, /doe/i/
, gets converted to /doe/i
and translated to the mongodb $regex
operator - if an error is thrown from any
pre
hook, the request fails and the operation is not executed; however, if an error is thrown from any post
hook, the request still succeeds, and the error is suppressed
limitations
- filtering properties of type map is not supported
- filtering and editing properties of array of objects is not supported yet
- sorting properties of type map and array is not supported
- not explicitly tested or designed with schema type mixed in mind. i don't think you should be using it anyway. if any of your properties are of type mixed, you should disable the property by setting
viewable: false
as not doing so might lead to unexpected behaviors