npm package: @kamalyb/mongoose-explore
Description: a somewhat **_opinionated_** and feature-rich **_admin panel_** for mongoose. built with [pug](https://pugjs.org/api/getting-started.html) and strongly inspired by [adminjs](https://adminjs.co)
Author: Kamal Yusuf
Latest version: 3.0.2

# mongoose explore

a somewhat **_opinionated_** and feature-rich **_admin panel_** for
mongoose. built with [pug](https://pugjs.org/api/getting-started.html) and
strongly inspired by [adminjs](https://adminjs.co)

## table of contents

- [mongoose explore](#mongoose-explore)
  - [table of contents](#table-of-contents)
  - [installation](#installation)
  - [prerequisites](#prerequisites)
  - [quick start](#quick-start)
  - [configuration options](#configuration-options)
    - [`mongoose` (required)](#mongoose-required)
    - [`rootpath`](#rootpath)
    - [`css`](#css)
    - [`datetime_formatter`](#datetime_formatter)
    - [`explorables`](#explorables)
    - [`query_runner`](#query_runner)
    - [`fallback_value`](#fallback_value)
    - [`timestamps`](#timestamps)
      - [`Timestamps` definition](#timestamps-definition)
    - [`titles`](#titles)
      - [`Titles` definition](#titles-definition)
    - [`theming`](#theming)
      - [`ThemingOptions` definition](#themingoptions-definition)
      - [color customization](#color-customization)
      - [default theme](#default-theme)
      - [css variables](#css-variables)
      - [css classes](#css-classes)
        - [button classes](#button-classes)
        - [form element classes](#form-element-classes)
      - [configuration example](#configuration-example)
    - [`version_key`](#version_key)
    - [`show_indexes`](#show_indexes)
    - [`bulk_delete`](#bulk_delete)
    - [`dashboard`](#dashboard)
      - [`Dashboard` definition](#dashboard-definition)
      - [example usage](#example-usage)
    - [`widgets`](#widgets)
      - [`Widget` definition](#widget-definition)
      - [example usage](#example-usage-1)
    - [`resources`](#resources)
      - [`ResourcesOptions` definition](#resourcesoptions-definition)
      - [complete example](#complete-example)
    - [`pages`](#pages)
      - [`CustomPage` definition](#custompage-definition)
      - [template rendering](#template-rendering)
      - [example](#example)
    - [`access_control`](#access_control)
      - [explorer-level access control](#explorer-level-access-control)
      - [`ExplorerAccessControlPolicy` definition](#exploreraccesscontrolpolicy-definition)
      - [example](#example-1)
      - [resource-level access control](#resource-level-access-control)
      - [`ResourceAccessControlPolicy` definition](#resourceaccesscontrolpolicy-definition)
      - [example](#example-2)
      - [key principles](#key-principles)
      - [custom pages and access control](#custom-pages-and-access-control)
    - [`indexes_ttl`](#indexes_ttl)
    - [`user_resolver`](#user_resolver)
    - [`title`](#title)
  - [events](#events)
    - [available events](#available-events)
    - [event type definitions](#event-type-definitions)
    - [suggested audit log schema](#suggested-audit-log-schema)
    - [example usage](#example-usage-2)
  - [features and behavior](#features-and-behavior)
    - [1. string filtering with regex](#1-string-filtering-with-regex)
    - [2. query identification: `is_explorer_operation`](#2-query-identification-is_explorer_operation)
      - [example usage](#example-usage-3)
    - [3. inclusion and exclusion patterns](#3-inclusion-and-exclusion-patterns)
    - [4. no models in UI](#4-no-models-in-ui)
  - [limitations](#limitations)

## installation

```bash
npm install @kamalyb/mongoose-explore

# or

yarn add @kamalyb/mongoose-explore

# or

pnpm add @kamalyb/mongoose-explore
```

## prerequisites

1. requires node >= 22, mongoose >= 9 and express >= 5
2. do not disable mongoose's schema types casting as `Model.castObject()` is
   used to accurately convert url-encoded data to align with the
   corresponding model's schema when creating and editing documents
3. if your application utilizes `helmet` middleware, allow the
   `unsafe-inline` and `self` script source directive in your content security policy

```typescript
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        "script-src": ["'unsafe-inline'", "'self'"],
        "script-src-attr": ["'unsafe-inline'"]
      }
    }
  })
);
```

## quick start

```typescript
import express from "express";
import mongoose from "mongoose";
import { MongooseExplorer } from "@kamalyb/mongoose-explore";
import "./models.js";

const app = express();

const explorer = new MongooseExplorer({ mongoose });

app.use(explorer.rootpath, explorer.router());

await mongoose.connect();

app.listen(port);
```

> voila! you now have an admin panel on _/admin/explorer_ to explore and
> manipulate your mongoose models

## configuration options

the `MongooseExplorer` constructor accepts a configuration object with the
following options:

### `mongoose` (required)

the mongoose instance to use for database operations

### `rootpath`

- type: `string`
- default: `/admin/explorer`
- description: the base URL path where the explorer will be mounted

```typescript
const explorer = new MongooseExplorer({
  mongoose,
  rootpath: "/custom-admin" // access via http://your-app.com/custom-admin
});
```

### `css`

- type: `string`
- description: specifies global or custom styles for the explorer. this can
  be an absolute path to a css file or a string containing inline css. the
  styles are applied globally or can be used for custom pages and HTML

```typescript
const explorer = new MongooseExplorer({
  mongoose,
  css: `
    .custom-class {
      color: red;
    }
  `,
  dashboard: {
    render: () => {
      return `
        <div>
          <p class="custom-class">hello world</p>
        </div>
      `;
    }
  }
});
```

### `datetime_formatter`

- type: `(date: Date) => string`
- default: 24-hour format with short month (e.g., "Mar 22, 2024, 13:45")
- description: formatted string representation of dates

```typescript
// default implementation
const explorer = new MongooseExplorer({
  mongoose,
  datetime_formatter: (date) =>
    date.toLocaleString("en-US", {
      month: "short",
      day: "numeric",
      year: "numeric",
      hour: "2-digit",
      minute: "2-digit",
      hour12: false
    })
});
```

### `explorables`

- type: `string[]`
- description: list of model names to expose in the explorer. if not
  provided, all models will be available. if set, only the resources
  included in it are available, rendering any `explorable: true` setting
  under individual resource configuration irrelevant. supports exclusions by
  prefixing model names with a `-` sign

```typescript
const explorer = new MongooseExplorer({
  mongoose,
  explorables: ["User", "Post", "Comment"] // only these models will be accessible
});

const explorer = new MongooseExplorer({
  mongoose,
  explorables: ["-Password"] // all models will be accessible except Password
});
```

### `query_runner`

- type: `boolean | string[]`
- default: `false`
- description: enable custom query execution and instantly view the result.
  can be limited to specific resources. it supports `find`, `findOne`,
  `aggregate`, `countDocuments`, and `estimatedDocumentCount` mongoose
  operations. query options are applicable to `find` and `findOne`
  operations only

```typescript
// enable for all resources
const explorer = new MongooseExplorer({
  mongoose,
  query_runner: true
});

// enable only for specific resources
const explorer = new MongooseExplorer({
  mongoose,
  query_runner: ["User", "Post"]
});
```

### `fallback_value`

- type: `string`
- default: `-`
- description: string or html content to display when a property has no
  value (null or undefined)

```typescript
const explorer = new MongooseExplorer({
  mongoose,

  // plain text
  fallback_value: "N/A",

  // or html for styled content
  fallback_value: "<span style="color: orange">no data</span>"
});
```

### `timestamps`

- type: `Timestamps`
- default: `{ created: "created_at", updated: "updated_at" }`
- description: maps your mongoose schema's timestamp field names to prevent
  their modification in the interface among other things

#### `Timestamps` definition

```typescript
interface Timestamps {
  created?: string;
  updated?: string;
}
```

```typescript
const explorer = new MongooseExplorer({
  mongoose,
  timestamps: {
    created: "insertedAt", // if your schema uses insertedAt
    updated: "modifiedAt" // if your schema uses modifiedAt
  }
});
```

### `titles`

- type: `Titles`
- default: `'explorer'`
- description: customize html page titles for different views and operations
  in the interface

#### `Titles` definition

```typescript
interface Titles {
  /**
   * @default "Dashboard | Explorer"
   */
  dashboard?: string;
  /**
   * @default `${resource} | Explorer`
   */
  list?: (resource: string) => string;
  /**
   * @default `${resource} - Document | Explorer`
   */
  view?: (resource: string, doc: object) => string;
  /**
   * @default `${resource} - Edit | Explorer`
   */
  edit?: (resource: string, doc: object) => string;
  /**
   * @default `${resource} - Create | Explorer`
   */
  create?: (resource: string) => string;
  /**
   * @default `${resource} - Query Runner | Explorer`
   */
  query_runner?: (resource: string) => string;
  /**
   * @default "Error | Explorer"
   */
  error?: (error: Error) => string;
}
```

```typescript
const explorer = new MongooseExplorer({
  mongoose,
  titles: {
    // static title for dashboard
    dashboard: "Admin Dashboard",

    // dynamic titles
    list: (resource) => `${resource} List`,
    view: (resource, doc) => `${doc.name} - ${resource} Details`,
    edit: (resource, doc) => `Edit ${resource}: ${doc.name}`,
    create: (resource) => `New ${resource}`,
    query_runner: (resource) => `Query ${resource}`,
    error: (error) => `Error: ${error.message}`
  }
});
```

examples of generated titles:

```typescript
// for a User model with document { name: "John Doe" }
{
  dashboard: "Admin Dashboard";
  list: "User List";
  view: "John Doe - User Details";
  edit: "Edit User: John Doe";
  create: "New User";
  query_runner: "Query User";
  error: "Error: Validation Failed";
}
```

### `theming`

- type: `ThemingOptions`
- description: customize the visual appearance of the interface

#### `ThemingOptions` definition

```typescript
type Theme = DeepRequired<ThemingOptions>;

interface ThemingOptions {
  colors?: Colors;
  font?: Font;
}

interface Colors {
  primary?: string;
  secondary?: string;
  danger?: string;
  warning?: string;
  success?: string;
  border?: string;
  text?: string;
  custom?: Record<string, `#${string}`>;
}

interface Font {
  url: string;
  family: string;
}

type Color =
  | "primary"
  | "secondary"
  | "success"
  | "danger"
  | "warning"
  | "dark"
  | "gray"
  | "white"
  | (string & {});

type Variant = "filled" | "outline" | "light" | "subtle";

type Size =
  | "xs"
  | "compact-xs"
  | "sm"
  | "compact-sm"
  | "md"
  | "compact-md"
  | "lg"
  | "compact-lg"
  | "xl"
  | "compact-xl";
```

#### color customization

- all color inputs generate CSS variables and classes
- creates `var(--color-{name})` and `.color-{name}`
- background fixed at `#101113` (dark shade, non-customizable)
- custom colors can be added through `custom` object

#### default theme

- predefined theme with Finlandica font
- default color palette for the interface

```typescript
{
  font: {
    family: `"Finlandica", sans-serif`,
    url: "https://fonts.googleapis.com/css2?family=Finlandica&display=swap"
  },
  colors: {
    primary: "#4f46e5",
    secondary: "#DCE546",
    danger: "#dc2626",
    success: "#059669",
    warning: "#d97706",
    border: "#4B5563",
    text: "#fff"
  }
};
```

#### css variables

the following CSS variables are available throughout the interface:

```css
/* typography */
--font-family
--font-size

/* colors */
--color-primary
--color-secondary
--color-danger
--color-success
--color-warning
--color-border
--color-text
--color-shade
--color-dark
--color-gray
--color-{custom-name}  /* generated for each custom color */

/* spacing */
--spacing-xs
--spacing-sm
--spacing-md
--spacing-lg
--spacing-xl

/* border radius */
--radius-xs
--radius-sm
--radius-md
--radius-lg
--radius-xl
```

#### css classes

##### button classes

the `.button` class must be combined with variant, color, and size modifiers
for styling:

```css
/* base class (no styling by itself) */
.button

/* size modifiers */
.button.size-xs
.button.size-sm
.button.size-md
.button.size-lg
.button.size-xl

/* compact variants */
.button.size-compact-xs  /* smaller version of xs */
.button.size-compact-sm  /* smaller version of sm */
.button.size-compact-md  /* smaller version of md */
.button.size-compact-lg  /* smaller version of lg */
.button.size-compact-xl  /* smaller version of xl */

/* variant modifiers */
.button.variant-filled
.button.variant-light
.button.variant-outline
.button.variant-subtle

/* color modifiers - generated for all predefined and custom colors */
.button.color-primary
.button.color-secondary
.button.color-success
/* etc... */
```

##### form element classes

```css
/* input */
.input
.input.size-xs
.input.size-sm
.input.size-md
.input.size-lg
.input.size-xl

/* textarea */
.textarea
.textarea.size-xs
.textarea.size-sm
.textarea.size-md
.textarea.size-lg
.textarea.size-xl

/* select */
.select
.select.size-xs
.select.size-sm
.select.size-md
.select.size-lg
.select.size-xl
```

#### configuration example

- customize theme colors and font
- add custom colors with `custom` property
- override default theme settings

```typescript
const explorer = new MongooseExplorer({
  mongoose,
  theming: {
    colors: {
      primary: "#4f46e5",
      custom: {
        coral: "#FF7F50"
      }
    },
    font: {
      family: '"Custom Font", sans-serif',
      url: "https://fonts.googleapis.com/css2?family=CustomFont&display=swap"
    }
  }
});

// button html example
<button class="button color-coral variant-filled size-compact-sm">
  Click me
</button>

// input html example
<input class="input size-md" type="text">
```

### `version_key`

- type: `string`
- default: `__v`
- description: the version key of your schema, if enabled

### `show_indexes`

- type: `boolean`
- default: `false`
- description: controls visibility of model indexes in the interface

### `bulk_delete`

- type: `boolean`
- default: `false`
- description: enable or disable bulk deletion of documents

### `dashboard`

- type: `Dashboard`
- description: customize the dashboard page content and styling. by default,
  the dashboard is empty. any html content returned by the render function
  will be wrapped in the interface layout to maintain consistent navigation

#### `Dashboard` definition

```typescript
interface Dashboard {
  /**
   * function to generate dashboard's html content
   */
  render?: (req: Request, theme: Theme) => string | Promise<string>;
  /**
   * additional html to inject into the <head> section.
   * useful for including external resources like stylesheets, remote scripts, etc
   */
  head?: string;
  /**
   * javascript for the dashboard page.
   * can either be an absolute file path (e.g., "C:/users/john/project/dashboard.js")
   * or inline javascript as a string
   */
  js?: string;
  /**
   * css styles for the dashboard page.
   * can either be an absolute file path (e.g., "C:/users/john/project/dashboard.css")
   * or inline css as a string
   */
  css?: string;
  /**
   * custom css styles for different dashboard components.
   * all style properties accept css rules as strings
   */
  styles?: {
    /**
     *  style container that holds all dashboard widgets
     */
    widgets?: string;
    /**
     * style main wrapper containing both rendered html and widgets container
     */
    wrapper?: string;
    /**
     * style statistical widget containers (displays single value metrics)
     */
    stat?: string;
    /**
     *  style pie chart widget containers
     */
    piechart?: string;
    /**
     * style bar chart widget containers
     */
    barchart?: string;
    /**
     * style tabular data widget containers
     */
    tabular?: string;
    /**
     * style doughnut chart widget containers
     */
    doughnutchart?: string;
    /**
     *  style line chart widget containers
     */
    linechart?: string;
    /**
     * style radar chart widget containers
     */
    radarchart?: string;
  };
}
```

#### example usage

```typescript
const explorer = new MongooseExplorer({
  mongoose,
  dashboard: {
    render: async (req, theme) => {
      const count = await User.countDocuments();

      return `
        <div class="dashboard-stats">
          <div class="stat-card">
            <h3>Total Users</h3>
            <p>${count}</p>
          </div>
        </div>
      `;
    },

    // include external resources
    head: `
      <link rel="stylesheet" href="https://cdn.example.com/custom-styles.css">
      <script src="https://cdn.example.com/animation-library.js"></script>
    `,

    // custom javascript (using absolute path)
    js: "C:/users/john/project/dashboard.js",
    // or inline javascript
    js: `
      // add custom event handlers
      document.querySelector(".stat-card).addEventListener("click", () => {
        //
      });
    `,

    css: `
      .stat-card {
        border-radius: var(--radius-md);
        box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 4px;
      }
    `,

    // custom styles for different components
    styles: {
      // style the wrapper containing everything
      wrapper: `
        padding: 2rem;
        background: #f5f5f5;
        display: grid;
        gap: 1rem;
      `,
      // style the widgets container
      widgets: `
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
        gap: 1rem;
        padding: 1rem;
      `,
      // style stat widget containers
      stat: `
        background: white;
        padding: 1.5rem;
        border-radius: 8px;
        box-shadow: 0 2px 4px rgba(0,0,0,0.1);
      `,
      // style chart widget containers
      piechart: `
        background: white;
        padding: 1rem;
        border-radius: 8px;
        min-height: 300px;
      `
    }
  }
});
```

### `widgets`

- type: `Widget[]`
- description: An array of widgets to display on the dashboard. each widget
  can be of different types (stat, bar chart, pie chart, tabular, doughnut
  chart, line chart, radar chart, custom)

#### `Widget` definition

```typescript
type Widget =
  | StatWidget
  | BarChartWidget
  | PieChartWidget
  | TabularWidget
  | DoughnutWidget
  | LineWidget
  | RadarWidget
  | CustomWidget;

interface StatWidget {
  type: "stat";
  key?: string;
  title: string;
  resolver: () => Promise<string | number>;
  /**
   * custom rendering function for the displayed value.
   * can return a plain string or html string to be rendered
   */
  render?: (value: string | number, theme: Theme) => string;
}

interface BarChartWidget {
  type: "bar chart";
  key?: string;
  title: string;
  resolver: () => Promise<{
    labels: string[];
    datasets: {
      label: string;
      data: number[];
    }[];
  }>;
}

interface PieChartWidget {
  type: "pie chart";
  key?: string;
  title: string;
  resolver: () => Promise<{
    labels: string[];
    data: number[];
  }>;
}

interface TabularWidget {
  type: "tabular";
  key?: string;
  title: string;
  resolver: () => Promise<object[]>;
  /**
   * determines whether table header is displayed
   */
  header?: boolean;
}

interface DoughnutWidget {
  type: "doughnut chart";
  key?: string;
  title: string;
  resolver: () => Promise<{
    labels: string[];
    data: number[];
  }>;
}

interface LineWidget {
  type: "line chart";
  key?: string;
  title: string;
  resolver: () => Promise<{
    labels: string[];
    datasets: {
      label: string;
      data: number[];
    }[];
  }>;
}

interface RadarWidget {
  type: "radar chart";
  key?: string;
  title: string;
  resolver: () => Promise<{
    labels: string[];
    datasets: {
      label: string;
      data: number[];
    }[];
  }>;
}

interface CustomWidget {
  type: "custom";
  key?: string;
  title: string;
  render: (theme: Theme) => string | Promise<string>;
  element?: {
    style?: string;
  };
}
```

**note on `key`**: explicitly for [access control](#access_control). acts as
a unique identifier for the widget. if `key` is absent, `title` will be used
as a fallback for compatibility. however, using unique `key` values is
highly recommended to avoid ambiguity

#### example usage

```typescript
const explorer = new MongooseExplorer({
  mongoose,
  widgets: [
    {
      type: "stat",
      title: "Total Users",
      resolver: async () => {
        return await User.countDocuments();
      },
      render: (value, theme) => `
        <div style="text-align: center">
          <h3 style="color: ${theme.colors.primary}">${value}</h3>
          <p>registered users</p>
        </div>
      `
    },

    {
      type: "bar chart",
      title: "Monthly Signups",
      resolver: async () => {
        const data = await User.aggregate([
          {
            $group: {
              _id: { $month: "$created_at" },
              count: { $sum: 1 }
            }
          }
        ]);

        return {
          labels: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
          datasets: [
            {
              label: "New Users",
              data: data.map((d) => d.count)
            }
          ]
        };
      }
    },

    {
      type: "custom",
      title: "Custom Content",
      render: () => `
        <div style="text-align: center; padding: 1rem;">
          <h3>Custom Widget</h3>
          <p>This is a fully custom widget!</p>
        </div>
      `,
      element: {
        style:
          "background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"
      }
    },

    {
      type: "pie chart",
      title: "User Distribution",
      resolver: async () => {
        const data = await User.aggregate([
          {
            $group: {
              _id: "$role",
              count: { $sum: 1 }
            }
          }
        ]);

        return {
          labels: data.map((d) => d._id),
          data: data.map((d) => d.count)
        };
      }
    },

    {
      type: "tabular",
      title: "User Data",
      header: true,
      resolver: async () => {
        return await User.find()
          .select("name email role")
          .sort({ created_at: -1 })
          .limit(10)
          .lean();
      }
    },

    {
      type: "doughnut chart",
      title: "Sales Breakdown",
      resolver: async () => {
        const data = await Sale.aggregate([
          {
            $group: {
              _id: "$category",
              total: { $sum: "$amount" }
            }
          }
        ]);

        return {
          labels: data.map((d) => d._id),
          data: data.map((d) => d.total)
        };
      }
    },

    {
      type: "line chart",
      title: "Monthly Revenue",
      resolver: async () => {
        const data = await Sale.aggregate([
          {
            $group: {
              _id: { $month: "$date" },
              total: { $sum: "$amount" }
            }
          }
        ]);

        return {
          labels: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
          datasets: [
            {
              label: "Revenue",
              data: data.map((d) => d.total)
            }
          ]
        };
      }
    },

    {
      type: "radar chart",
      title: "User Skills",
      resolver: async () => {
        const data = await Skill.aggregate([
          {
            $group: {
              _id: "$skill",
              average: { $avg: "$level" }
            }
          }
        ]);

        return {
          labels: data.map((d) => d._id),
          datasets: [
            {
              label: "Skill Levels",
              data: data.map((d) => d.average)
            }
          ]
        };
      }
    }
  ]
});
```

### `resources`

- type: `ResourcesOptions`
- default: `{}`
- description: configures how each mongoose model is handled in the explorer

#### `ResourcesOptions` definition

```typescript
type ResourcesOptions = Record<string, ResourceOptions>;

interface ResourceOptions {
  /**
   * whether the resource is explorable
   *
   * @default true
   */
  explorable?: boolean;
  /**
   * allow creating new documents
   *
   * @default true
   */
  creatable?: boolean;
  /**
   * allow document deletion
   *
   * @default true
   */
  deletable?: boolean | ((doc: object) => boolean);
  /**
   * allow document editing
   *
   * @default true
   */
  editable?: boolean | ((doc: object) => boolean);
  /**
   * allow sorting
   *
   * @default true
   */
  sortable?: boolean;
  /**
   * number of documents displayed per page.
   * can be changed from the interface
   */
  limit?: number;
  /**
   * properties which are shown in the interface.
   * this takes precedence over the `viewable` option at the property level
   */
  viewables?: string[];
  /**
   * properties which can be used as a filtering criteria.
   * this takes precedence over the `filterable` option at the property level
   */
  filterables?: string[];
  /**
   * properties which can be created.
   * this takes precedence over the `creatable` option at the property level
   */
  creatables?: string[];
  /**
   * properties which can be edited.
   * this takes precedence over the `editable` option at the property level
   */
  editables?: string[];
  /**
   * properties which can be sorted.
   * this takes precedence over the `sortable` option at the property level
   */
  sortables?: string[];
  /**
   * open referenced documents in new tab
   *
   * @default false
   */
  ref_newtab?: boolean;
  /**
   * custom computed fields that appear in the interface
   */
  virtuals?: Record<string, (doc: object) => string>;
  /**
   * display model indexes
   *
   * @default false
   */
  show_indexes?: boolean;
  /**
   * enable custom query execution and instantly view the result
   */
  query_runner?: {
    /**
     * this takes precedence over the option `query_runner` option at the explorer level
     *
     * @default false
     */
    enabled?: boolean;
    /**
     * predefined queries that can be run with a single click.
     * the concept is similar to PostgreSQL views where you dont have to type the query each time you need it
     */
    presets?: {
      name: string;
      resolver: () => Promise<any>;
    }[];
  };
  bulk_delete?: {
    /**
     * enables bulk deletion of documents.
     * this takes precedence over `bulk_delete` option at the explorer level
     *
     * @default false
     */
    enabled?: boolean;
    /**
     * determines how deletion is performed.
     * documents are deleted using `Document.deleteOne()` if true, otherwise the
     * default behavior is `Model.deleteMany()`
     *
     * @default false
     */
    use_document?: boolean;
  };
  /**
   * cascade delete behavior for dependent documents.
   * if a function is passed, the session is defined if mongodb is in a replica set.
   * uses transaction to delete documents if mongodb is in a replica set or `Promise.all` otherwise.
   * currently not supported for bulk delete
   */
  cascade_delete?:
    | {
        /**
         * related model to delete from
         */
        model: mongoose.Model;
        relation: {
          /**
           * field in the current model
           */
          local_field: string;
          /**
           * field in the related model
           */
          foreign_field: string;
        };
      }[]
    | ((
        doc: mongoose.Document,
        session?: mongodb.ClientSession
      ) => Promise<void>);
  /**
   * document operations
   */
  actions?: Actions;
  /**
   * individual property configuration
   */
  properties?: Record<string, PropertyOptions>;
  /**
   * access control settings for the resource
   * see [resource-level access control](#resource-level-access-control)
   */
  access_control?: ResourceAccessControlPolicy;
}

interface Actions {
  custom?: CustomActions;
  /**
   * override default document creation (new Model().save())
   */
  create?: (body: AnyObject) => Promise<mongoose.Document>;
  /**
   * override default deletion (Document.deleteOne())
   */
  delete?(doc: mongoose.Document): Promise<mongoose.Document>;
  /**
   * override default update (Document.set().save({ validateModifiedOnly: true, timestamps }))
   */
  update?(
    doc: mongoose.Document,
    body: AnyObject,
    timestamps: boolean
  ): Promise<mongoose.Document>;
}

interface CustomActions {
  /**
   * actions that can be performed on multiple selected documents
   */
  bulk?: {
    /**
     * must be unique
     */
    operation: string;
    /**
     * operation implementation
     */
    execute: (ids: string[]) => void | Promise<void>;
    /**
     * window.confirm prompt message before executing handler.
     * if true default message "are you sure?" is used, otherwise no prompt
     * and handler is executed immediately
     *
     * @default false
     */
    guard?: boolean | string;
    element?: {
      /**
       * visual variant for the button, `filled`, `outline`, `light`, or `subtle`
       *
       * @default outline
       */
      variant?: Variant;
      /**
       * color of the button. can be one of the predefined colors (primary, secondary, success,
       * danger, warning, dark, gray, white) or any custom color defined in theming.colors.custom
       *
       * @default white
       */
      color?: Color;
      /**
       * size of the button, `xs`, `compact-xs`, `sm`, `compact-sm`, `md`, `compact-md`, `lg`,
       * `compact-lg`, `xl` and `compact-xl`
       *
       * @default compact-sm
       */
      size?: Size;
      /**
       * label of the operation's button
       *
       * @default ${operation}
       */
      label?: string;
      /**
       * extra styling for the operation's button
       */
      style?: string;
    };
  }[];
  /**
   * actions that can be performed on individual documents
   */
  document?: {
    /**
     * must be unique
     */
    operation: string;
    execute: (doc: mongoose.Document) => void | Promise<void>;
    /**
     * determines whether the action is applicable to the document
     */
    applicable?: (doc: object) => boolean;
    guard?: boolean | string;
    element?: {
      variant?: Variant;
      color?: Color;
      size?: Size;
      label?: string;
      style?: string;
    };
  }[];
}

interface PropertyOptions {
  /**
   * custom display label for the field in the interface
   */
  label?: string;
  /**
   * controls whether this field can be modified
   */
  editable?: boolean;
  /**
   * enables sorting by this field
   */
  sortable?: boolean;
  /**
   * controls field visibility in the interface
   */
  viewable?: boolean;
  /**
   * determines whether this field can be used as a filtering criteria
   */
  filterable?: boolean;
  /**
   * determines if this field is required when creating and editing.
   * this is purely for UI purposes and does not modify it at the schema level
   */
  required?: boolean | ((value?: any) => boolean);
  /**
   * allows the creation of this field
   */
  creatable?: boolean;
  /**
   * use a textarea input instead of a regular text input.
   * this is only considered if property is a string
   */
  textarea?: boolean;
  /**
   * displays the field value as an image instead of the url string,
   * assuming the value is a valid image url
   */
  as_image?: boolean;
  /**
   * customize how fields are displayed for different views.
   * renderer functions are expected to return html as a string, and input
   * elements in forms are expected to contain `name` attribute matching the field name
   */
  renderers?: Renderers;
}

interface Renderers {
  /**
   * customize how the value appears in the table view
   */
  list?: (value: any) => string;
  /**
   * customize the input field in create form
   */
  create?: (enum_values: any[] | null) => string;
  /**
   * customize how the filter input appears
   */
  filter?: (enum_values: any[] | null) => string;
  /**
   * customize how the value appears in detail view
   */
  view?: (value: any) => string;
  /**
   * customize the input field in edit form
   */
  edit?: (params: { value: any; enum_values: any[] | null }) => string;
}
```

#### complete example

```typescript
const explorer = new MongooseExplorer({
  mongoose,
  resources: {
    User: {
      explorable: true,
      creatable: false,
      deletable: (user) => user.role !== "admin",
      editable: (user) => !user.active,
      sortable: true,
      limit: 50,
      viewables: ["email", "role"],
      filterables: ["role"],
      creatables: ["email", "username", "role"], // redundant since creatable is false
      editables: ["role", "active"],
      sortables: ["role", "active"],
      ref_newtab: true,
      virtuals: {
        info: (user) => `${user.email} - ${user.role}`
      },
      show_indexes: true,
      query_runner: {
        enabled: true,
        presets: [
          {
            name: "Inactive Users",
            resolver: () =>
              User.find({
                last_login: {
                  $lt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
                }
              }).lean()
          }
        ]
      },
      actions: {
        create: async (body) => {
          const user = await User.create(body);

          await services.email.verification(user.email);

          return user;
        },
        delete: async (user) => {
          await user.deleteOne();

          await posthog.capture("user.deleted", user.toObject());

          return user;
        },
        custom: {
          bulk: [
            {
              operation: "ban",
              execute: async (ids) => {
                await User.updateMany(
                  { _id: { $in: ids } },
                  { $set: { is_banned: true } }
                );
              },
              guard: true,
              element: {
                variant: "filled",
                color: "danger",
                size: "lg",
                label: "BAN",
                style: "border-radius: 10px"
              }
            }
          ],
          document: [
            {
              operation: "promote-to-admin",
              execute: async (user) => {
                await user.set("role", "admin").save();
              },
              applicable: (user) => user.role !== "admin",
              guard: "promote to admin fr?",
              element: {
                variant: "outline",
                color: "primary",
                size: "md",
                label: "Promote To Admin",
                style: "font-style: italic; font-weight: bold"
              }
            }
          ]
        }
      },
      bulk_delete: {
        enabled: true,
        use_document: true
      },
      cascade_delete: [
        {
          model: Post,
          relation: {
            local_field: "_id",
            foreign_field: "author_id"
          }
        },
        {
          model: Notification,
          relation: {
            local_field: "_id",
            foreign_field: "recipient_id"
          }
        }
      ],
      // or manually
      cascade_delete: async (doc, session) => {
        if (!session)
          await Promise.all([
            Post.deleteMany({ author_id: doc._id }),
            Notification.deleteMany({ recipient_id: doc._id })
          ]);
        else {
          await Post.deleteMany({ author_id: doc._id }, { session });

          await Notification.deleteMany({ recipient_id: doc._id }, { session });
        }
      },
      properties: {
        email: {
          label: "E-Mail",
          editable: false,
          sortable: false,
          viewable: true,
          filterable: false,
          required: true,
          creatable: true,
          textarea: false,
          as_image: false,
          renderers: {
            list: (email) => mask(email),
            view: (email) => mask(email),
            create: () =>
              `<input type="email" name="email" class="input size-sm" style="border-radius: 20px"></input>`
          }
        },
        /**
         * target properties nested within an object by using dot notation
         */
        "profile.settings.notificatons": {}
      }
    }
  }
});
```

### `pages`

- type: `CustomPage[]`
- description: custom pages that will be rendered at custom routes

#### `CustomPage` definition

```typescript
interface CustomPage {
  /**
   * path where the custom page will be mounted.
   * this value must not be prefixed with `/` and must be unique
   */
  path: string;
  /**
   * absolute path to the custom pug file for rendering the page
   */
  pug: string;
  /**
   * the label for the custom page link, which will be displayed in the navigation.
   * if not provided, defaults to the `path` value
   */
  label?: string;
  /**
   * additional html to inject into the <head> section.
   * useful for including external resources like stylesheets, remote scripts, etc
   */
  head?: string;
  /**
   * page specific javascript.
   * can either be an absolute path to a javascript file or inline javascript as a string
   */
  js?: string;
  /**
   * page specific styles.
   * can either be an absolute path to a css file or inline css as a string
   */
  css?: string;
  /**
   * define local variables for the page
   */
  locals?: (req: Request) => object | Promise<object>;
  /**
   * action handler for the custom page. this will be triggered when a POST
   * request is made in the page route.
   * to use this, include a form with `method="post"` in the page.
   * `params.body` is the parsed url-encoded body, while the raw body is still available via `req.body`
   */
  action?: (params: { req: Request; body: object }) => void | Promise<void>;
  /**
   * joi schema for validating `req.body` in the action handler.
   * the following validation options are applied by default:
   * - `allowUnknown: false`
   * - `presence: "required"`
   * - `abortEarly: false`
   * - `errors.wrap.label: false`
   */
  schema?: joi.ObjectSchema | ((joi: joi.Root) => joi.ObjectSchema);
  /**
   * html page title
   * @default `${page.label ?? page.path} | Explorer`
   */
  title?: string;
}
```

#### template rendering

within the pug template, you'll have access to the following:

- `theme: Theme`: the theme object. see [theming](#theming) for more details
- `link: (path: string) => string`: a function that generates links to other
  custom pages

#### example

```typescript
new MongooseExplorer({
  mongoose,
  pages: [
    {
      path: "analytics",
      pug: "/path/to/custom-analytics.pug",
      label: "Analytics Dashboard",
      locals: async (req) => {
        const analytics = await calculcate_analytics();

        return {
          user: req.user,
          analytics
        };
      }
    }
  ]
});
```

### `access_control`

- type: `ExplorerAccessControlPolicy`
- description: the access control system allows you to define granular
  permissions for different roles at both the explorer and resource levels. this
  provides fine-grained control over what actions users can perform and what
  data they can access

#### explorer-level access control

#### `ExplorerAccessControlPolicy` definition

```typescript
interface ExplorerAccessControlPolicy {
  /**
   * determines user's role from the request.
   * crucial for the entire access control system
   */
  role_resolver: (req: Request) => string;
  /**
   * role-based access rules
   */
  rules?: Record<Role, ExplorerAccessControlRule>;
  /**
   * dynamically modify permissions
   */
  augment?: (
    role: string,
    req: Request,
    rule: ExplorerAccessControlRule
  ) =>
    | string
    | { rule: ExplorerAccessControlRule; replace?: boolean }
    | Promise<string>
    | Promise<{ rule: ExplorerAccessControlRule; replace?: boolean }>;
}

interface ExplorerAccessControlRule {
  /**
   * controls which pages are accessible to users based on their roles.
   * page names or "*" for full access.
   * or object specifying view/action permissions per page
   */
  pages?: string[] | Record<string, PageAccessControl>;
  /**
   * controls which resources are accessible to users based on their roles
   */
  resources?: string[];
  /**
   * controls which widgets are accessible to users based on their roles
   */
  widgets?: string[];
}

interface PageAccessControl {
  /**
   * whether the role can view the page
   */
  view: boolean;
  /**
   * whether the role can execute the page's action
   */
  action: boolean;
}

type Role = string;
```

#### example

```typescript
new MongooseExplorer({
  mongoose,
  access_control: {
    role_resolver: (req) => req.user.role,
    rules: {
      "super-admin": {
        resources: ["*"], // full access to all resources
        pages: ["*"], // full access to all pages and actions
        widgets: ["*"] // full access to all widgets
      },
      admin: {
        resources: ["User", "Post", "AuditLog", "Ticket"], // access to these specific resources
        pages: {
          "*": {
            // default permissions for all pages
            view: true,
            action: false
          },
          "emergency-actions": {
            // override for specific page
            view: false,
            action: false
          }
        },
        widgets: ["total-users", "monthly-signups"]
      },
      moderator: {
        resources: [], // no access to any resource
        pages: ["content-moderation"],
        widgets: [] // no access to any widget
      }
    },
    augment: async (role, req, rule) => {
      if (role !== "super-admin" && (await is_on_duty(req.user)))
        return {
          rule: {
            pages: {
              "emergency-actions": {
                view: true,
                action: true
              }
            }
          }
        };

      return { rule: {} };
    }
  }
});
```

#### resource-level access control

#### `ResourceAccessControlPolicy` definition

```typescript
interface ResourceAccessControlPolicy {
  rules: Record<Role, ResourceAccessControlRule>;
  augment?: (
    role: string,
    req: Request,
    rule: ResourceAccessControlRule
  ) =>
    | string
    | { rule: ResourceAccessControlRule; replace?: boolean }
    | Promise<string>
    | Promise<{ rule: ResourceAccessControlRule; replace?: boolean }>;
}

interface ResourceAccessControlRule {
  /**
   * allowed operations on the resource
   */
  operations: (
    | "create"
    | "read"
    | "update"
    | "delete"
    | "bulk_delete"
    | "query_runner"
    | "*"
  )[];
  /**
   * field-level access control
   */
  fields?: {
    /**
     * fields that can be viewed
     */
    viewables?: string[];
    /**
     * fields that can be set during creation of documents of the resource
     */
    creatables?: string[];
    /**
     * fields that can be modified
     */
    editables?: string[];
    /**
     * fields that can be used for filtering
     */
    filterables?: string[];
    /**
     * fields that can be used for sorting
     */
    sortables?: string[];
  };
  /**
   * query runner permissions
   */
  query_runner?: {
    /**
     * allowed operation
     */
    operations?: (
      | "find"
      | "findOne"
      | "aggregate"
      | "countDocuments"
      | "estimatedDocumentCount"
      | "*"
    )[];
    /**
     * allowed preset queries
     * "*" for all presets or specific preset names
     */
    presets?: string[];
  };
  /**
   * custom actions permissions
   */
  actions?: {
    /**
     * allowed bulk actions
     * "*" for all actions
     */
    bulk?: string[];
    /**
     * allowed document actions
     * "*" for all actions
     */
    document?: string[];
  };
}
```

#### example

```typescript
{
  resources: {
    User: {
      access_control: {
        rules: {
          admin: {
            operations: ["read", "update"],
            fields: {
              viewables: ["*"],
              editables: ["status", "role"],
              filterables: ["status", "role", "created_at"],
              sortables: ["created_at", "status"]
            },
            query_runner: {
              operations: ["find", "countDocuments"],
              presets: ["active users", "recent signups"]
            },
            actions: {
              bulk: ["verify emails"],
              document: ["send verification email", "send welcome email"]
            }
          },
          moderator: {
            operations: ["read"],
            fields: {
              viewables: ["username", "email", "status"],
              filterables: ["status"],
              sortables: ["created_at"],
              editables: []
            },
            query_runner: {
              operations: ["find"],
              presets: ["reported users"]
            },
            actions: {
              document: ["flag for review"]
            }
          }
        },
        augment: async (role, req, rule) => {
          if (role === "moderator" & await has_temporary_elevated_access(req.user))
            return {
              rule: {
                operations: ["create", "delete"],
                fields: {
                  editables: ["*"]
                }
              }
            };

          return { rule: {} };
        }
      }
    }
  }
}
```

#### key principles

1. the `role_resolver` is the single source of truth for roles. it determines
   the user's role, which is used for both explorer-level and resource-level
   access control.
2. if no access control rules are specified for a role or resource, full access
   is granted by default.
3. in custom pages, a `can_execute_action` variable is available as a local
   variable. this can be used to conditionally render action forms based on the
   user's permissions.
4. use `*` wildcard for full access in any category.
5. the `augment` function allows for dynamic modification of access rules
   based on runtime conditions.
   - can return three types of modification:
     - a new role string to change the user's role
     - a modified rule object with optional `replace` flag
     - an object with a new rule and `replace` flag
   - if `replace` is `true`, the returned rule completely replaces the
     existing rule, otherwise the rule is merged with the existing rule
6. returning `"*"` wildcard from the `augment` function grants full access
   by removing all restrictions. this is a shorthand alternative to
   returning a complete rule structure with wildcards.
7. explorer-level access control and resource-level access control are distinct
   features and do not directly interact with each other. however, the
   `role_resolver` function, which is configured at the explorer level, is
   crucial for resource-level access control.
8. exclusion patterns using `-` prefix are supported for explorer-level and
   resource-level rules (except fields).
9. when using string arrays in `ExplorerAccessControlRule.pages`, page names
   are case-insensitive when matching against custom page paths. however,
   all other string configurations throughout the access control system
   (including `pages` when using object syntax) require exact case matching.

#### custom pages and access control

```jade
if can_execute_action
  form(method="post")
    // form fields
    button(type="submit") submit
```

this ensures that the action form is only displayed to users with the
appropriate permissions, as determined by the access control system

### `indexes_ttl`

- type: `number`
- default: `43200000` (12 hours)
- description: controls how long resource indexes are cached in memory, must be
  specified in milliseconds.

### `user_resolver`

- type: `(req: Request) => { username: string; avatar?: string; logout: { path: string; method: "get" | "post"; }; }`
- description: a function that resolves user information for the explorer. this
  adds a dropdown menu to the UI with the user's username, an avatar and a logout
  button. the function should return an object with:
  - `username`: the user's name to display in the UI
  - `avatar` (optional): a url to the user's avatar image. if not provided, a
    default avatar will be generated
  - `logout`: an object containing `path` and `method` for the logout endpoint.
    the method is expected to be either `get` or `post`

```typescript
const explorer = new MongooseExplorer({
  mongoose,
  user_resolver: (req) => {
    return {
      username: req.user.email,
      avatar: req.user.avatar_url,
      logout: {
        method: "post",
        path: "/api/logout"
      }
    };
  }
});

// the logout button will send a POST request to the "/api/logout" endpoint.
// in the logout endpoint, `req.query.origin` will be `"explorer"` so you
// can identify that the request is from the explorer
```

### `title`

- type: `string`
- default: `explorer`
- description: customizes the UI's title

## events

event system that allows you to hook into various events that occur within the
the explorer. this is particularly useful for implementing audit logs of actions
performed by users, monitoring system activity, or triggering side effects

### available events

- `create`: triggered when a new document is created
- `update`: triggered when a document is modified
- `delete`: triggered when a document is deleted
- `bulk_delete`: triggered when multiple documents are deleted
- `query_runner`: triggered when a custom query is executed
- `bulk_action`: triggered for bulk operations
- `document_action`: triggered for single document operations
- `page_action`: triggered for custom page interactions

each event passes two parameters to the handler function:

- `req`: the request object
- `event`: an object containing details about the event

### event type definitions

```typescript
interface CreateEvent {
  timestamp: Date;
  model: string;
  operation: "create";
  doc: RawDoc;
  /**
   * comes from the input when creating documents
   */
  note: string | undefined;
}

interface UpdateEvent {
  timestamp: Date;
  model: string;
  operation: "update";
  doc: RawDoc;
  changes: {
    previous: AnyObject;
    current: AnyObject;
  };
  /**
   * comes from the input when editing documents
   */
  note: string | undefined;
}

interface DeleteEvent {
  timestamp: Date;
  model: string;
  operation: "delete";
  doc: RawDoc;
}

interface BulkDeleteEvent {
  timestamp: Date;
  model: string;
  operation: "bulk_delete";
  document_ids: mongoose.Types.ObjectId[];
}

interface QueryRunnerEvent {
  timestamp: Date;
  model: string;
  operation: "query_runner";
  sub_operation: string;
  query: AnyObject | mongoose.PipelineStage[];
  options: mongoose.QueryOptions;
}

interface BulkActionEvent {
  timestamp: Date;
  model: string;
  operation: "bulk_action";
  sub_operation: string;
  document_ids: mongoose.Types.ObjectId[];
}

interface DocumentActionEvent {
  timestamp: Date;
  model: string;
  operation: "document_action";
  sub_operation: string;
  doc: RawDoc;
}

interface PageActionEvent {
  timestamp: Date;
  operation: "page_action";
  page: string;
  body: AnyObject;
}

type RawDoc = {
  _id: mongoose.Types.ObjectId;
} & AnyObject;
```

### suggested audit log schema

to keep an audit log, you can create a mongoose model based on the events
described. note that this is just a suggestion - you can modify it according to
your specific needs

```typescript
const ExplorerAuditLogSchema = new mongoose.Schema({
  timestamp: {
    type: Date,
    required: true
  },
  model: {
    type: String,
    required: true
  },
  actor_id: {
    type: mongoose.Schema.Types.ObjectId,
    ref: "User",
    required: true
  },
  operation: {
    type: String,
    required: true,
    enum: [
      "create",
      "update",
      "delete",
      "bulk_delete",
      "query_runner",
      "bulk_action",
      "document_action",
      "page_action"
    ]
  },
  document_id: {
    type: mongoose.Schema.Types.ObjectId,
    refPath: "model"
  },
  document_ids: {
    type: [mongoose.Schema.Types.ObjectId],
    default: undefined,
    refPath: "model"
  },
  changes: {
    previous: mongoose.Schema.Types.Mixed,
    current: mongoose.Schema.Types.Mixed
  },
  query: mongoose.Schema.Types.Mixed,
  query_options: mongoose.Schema.Types.Mixed,
  page: String,
  body: mongoose.Schema.Types.Mixed,
  metadata: mongoose.Schema.Types.Mixed
});

ExplorerAuditLogSchema.index({ timestamp: -1 });
ExplorerAuditLogSchema.index({ operation: 1 });
ExplorerAuditLogSchema.index({ model: 1 });
ExplorerAuditLogSchema.index({ actor_id: 1 });
ExplorerAuditLogSchema.index({ document_id: 1 }, { sparse: true });
ExplorerAuditLogSchema.index({ document_ids: 1 }, { sparse: true });

const ExplorerAuditLog = mongoose.model(
  "ExplorerAuditLog",
  ExplorerAuditLogSchema
);
```

### example usage

```typescript
const explorer = new MongooseExplorer({ mongoose });

explorer.on("create", async (req, event) => {
  await ExplorerAuditLog.create({
    operation: event.operation,
    model: event.model,
    document_id: event.doc._id,
    actor_id: req.user._id,
    timestamp: event.timestamp,
    metadata: {
      note: event.note
    }
  });
});

explorer.on("update", async (req, event) => {
  await ExplorerAuditLog.create({
    operation: event.operation,
    model: event.model,
    document_id: event.doc._id,
    changes: event.changes,
    actor_id: req.user._id,
    timestamp: event.timestamp,
    metadata: {
      note: event.note
    }
  });
});

explorer.on("delete", async (req, event) => {
  await ExplorerAuditLog.create({
    operation: event.operation,
    model: event.model,
    timestamp: event.timestamp,
    actor_id: req.user._id,
    metadata: {
      deleted_document: event.doc
    }
  });
});
```

remember that this is just a suggested schema - you can modify it based on your
specific needs. the key is to capture the information that's most relevant for
your use case

## features and behavior

### 1. string filtering with regex

the interface supports filtering strings using regex patterns enclosed
within `/` delimiters. **example**: a filter input of `/doe/i/` is processed
as `/doe/i` and applied using mongodb's `$regex` operator

### 2. query identification: `is_explorer_operation`

- type: `(subject: mongoose.Query | mongoose.Document | mongoose.Aggregate) => boolean`
- description: a utility function to determine if any operation originates
  from the explorer. this is helpful for configuring middlewares that should
  behave differently for operations originated from the explorer

#### example usage

```typescript
import { is_explorer_operation } from "@kamalyb/mongoose-explore";

schema.pre(/^find/, function (next) {
  if (!is_explorer_operation(this)) this.where({ deleted: { $ne: true } });

  next();
});

schema.pre("aggregate", function (next) {
  if (!is_explorer_operation(this))
    this.pipeline().unshift({ $match: { deleted: { $ne: true } } });

  next();
});

schema.pre("deleteOne", { query: false, document: true }, function (next) {
  if (is_explorer_operation(this)) something();

  next();
});

schema.post("save", function (next) {
  if (!is_explorer_operation(this)) another();

  next();
});
```

### 3. inclusion and exclusion patterns

- inclusion and exclusion patterns cannot be mixed in the same array
- if any item in an array starts with `-`, the entire array is treated as an
  exclusion list

### 4. no models in UI

in the edge case where no models are shown in the explorer, it is likely because
a new instance of `MongooseExplorer` was created before mongoose (models) was
initialized. this results in `mongoose.models` being an empty object, leading the
`MongooseExplorer` to have no models to display. ensure all models are loaded and
registered before initializing the `MongooseExplorer`

## limitations

1. filtering properties of type map, mixed, and array of objects is not supported
2. sorting properties of type map, mixed, and array is not supported