{"id":13566133,"url":"https://github.com/simov/express-admin","last_synced_at":"2025-05-15T08:06:25.791Z","repository":{"id":8645784,"uuid":"10295923","full_name":"simov/express-admin","owner":"simov","description":"MySQL, MariaDB, PostgreSQL, SQLite admin for Node.js","archived":false,"fork":false,"pushed_at":"2023-02-13T12:30:34.000Z","size":1928,"stargazers_count":1189,"open_issues_count":48,"forks_count":221,"subscribers_count":58,"default_branch":"main","last_synced_at":"2025-05-14T23:43:39.458Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/simov.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null}},"created_at":"2013-05-26T09:27:24.000Z","updated_at":"2025-05-09T09:18:13.000Z","dependencies_parsed_at":"2024-01-16T23:30:09.047Z","dependency_job_id":"bfa342f3-5576-4572-98fd-bd3bada74ba3","html_url":"https://github.com/simov/express-admin","commit_stats":{"total_commits":411,"total_committers":13,"mean_commits":"31.615384615384617","dds":0.03406326034063256,"last_synced_commit":"9e5dbc1ef7d8d02726e3021a113cbf81c19e576e"},"previous_names":[],"tags_count":34,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/simov%2Fexpress-admin","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/simov%2Fexpress-admin/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/simov%2Fexpress-admin/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/simov%2Fexpress-admin/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/simov","download_url":"https://codeload.github.com/simov/express-admin/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":254301431,"owners_count":22047904,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-08-01T13:02:02.854Z","updated_at":"2025-05-15T08:06:25.726Z","avatar_url":"https://github.com/simov.png","language":"JavaScript","funding_links":[],"categories":["JavaScript","Web 后端"],"sub_categories":[],"readme":"\n# Express Admin\n\n[![npm-version]][npm] [![snyk-vulnerabilities]][snyk]\n\n\u003e _MySQL, MariaDB, PostgreSQL, SQLite admin for Node.js_\n\n\u003cdetails\u003e\u003csummary\u003ebrief history\u003c/summary\u003e\n\nExpress Admin is a tool for creating end user administrative interfaces for relational databases in (literally) _less than 10 minutes_.\n\nIt was initially conceived back in 2012 and it was released officially in 2013. The user interface is built with [Bootstrap] (v3.3.1) and it is fully responsive and it works on any device. It also comes with all [Bootswatch] themes for that version of Bootstrap.\n\nThe entirety of the admin consists of just 3 views: main view, list view, and edit view. The main view lists all tables available inside the database and optionally a list of custom views. The list view lists table records with support for pagination and a powerful filtering widget for filtering and ordering the list. The edit view is responsible for viewing and editing database records and is capable of correctly modeling any table relationship, and it supports rich user input controls, such as date/time pickers and text editors.\n\nThe rendering is done entirely on the backend using [Hogan.js] (a [mustache.js] derivative). The frontend is using [jQuery] (v1.9.1) mainly for initializing the beautiful plugins: [Chosen] (v1.2.0) for rendering select boxes for many-to-one and many-to-many relationships and [Bootstrap Datepicker] for rendering various time and date pickers.\n\nAt the heart of the admin is the `settings.json` file. The `settings.json` file contains a robust data structure responsible for configuring all aspects of the admin, such as: table relationships, table visibility inside the main views, column settings such as: type and input control to use, verbose name, visibility etc., view settings, filter configuration etc.\n\nLastly, the admin also comes with various extension points for: custom static files on the frontend, custom views on the backend, and pre/post save hooks to augment the behavior of the admin.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\u003csummary\u003equick preview\u003c/summary\u003e\n\n**How it Looks**\n\n- Table Relationships\n  - [One to Many][example-one-to-many]\n  - [Many to Many][example-many-to-many]\n  - [Many to One][example-many-to-one]\n  - [One to One][example-one-to-one]\n- [Control Types][example-control-types]\n- [Complex Inline][example-complex-inline]\n- [Listview Filter][example-listview-filter] _(click on the little funnel icon next to the page header)_\n- [Custom Views][example-custom-views]\n\n**Examples**\n\nGet the admin up and running on your localhost in less than 10 minutes and play around with the **[examples]**.\n\n**Tests**\n\nExpress Admin comes with a comprehensive integration **[tests]** suite: https://vimeo.com/795985128\n\n\u003c/details\u003e\n\n## Table of Contents\n\n- **[Middleware](#middleware)**\n- **[Configuration](#configuration)**\n  - [config](#configuration-config) / [settings](#configuration-settings) / [users](#configuration-users) / [custom](#configuration-custom)\n- **[Relationships](#relationships)**\n  - [One to Many](#relationships-one-to-many) / [Many to Many](#relationships-many-to-many) / [Many to One](#relationships-many-to-one) / [One to One](#relationships-one-to-one) / [Compound Primary Key](#compound-primary-key)\n- **[Filter](#filter)**\n- **[Customization](#customization)**\n  - [Custom Static Files](#custom-static-files) / [Custom Views](#custom-views) / [Event Hooks](#custom-event-hooks)\n- **[Hosting](#hosting)**\n  - [Nginx](#hosting-nginx) / [Multiple Admins](#hosting-multiple-admins)\n- **[Examples]**\n- **[Tests]**\n\n---\n\n## Middleware\n\nExpress Admin is an [Express.js] middleware:\n\n\n```js\nvar express = require('express')\nvar admin = require('express-admin')\n\nexpress()\n  .use(admin({\n    config: require('config.json'),\n    settings: require('settings.json'),\n    users: require('users.json'),\n    custom: require('custom.json'),\n  }))\n  .listen(3000)\n```\n\nAt the minimum you need Express.js, Express Admin, and a database driver:\n\n```json\n{\n  \"dependencies\": {\n    \"express\": \"^4.18.2\",\n    \"express-admin\": \"^2.0.0\",\n    \"mysql\": \"^2.18.1\"\n  }\n}\n```\n\nExpress Admin was tested and is known to work with the following database drivers:\n\n| Driver        | Version   | Engine\n| :-            | -:        | :-\n| **[mysql]**   | `^2.18.1` | **MySQL** _(from v5 up to latest)_ **MariaDB** _(from v10.0 up to latest)_\n| **[pg]**      | `^8.9.0`  | **PostgreSQL** _(from v9 up to latest)_\n| **[sqlite3]** | `^5.1.4`  | **SQLite** _(from v3.8.0 up to latest)_\n\n---\n\n## Configuration\n\n| Key        | Availability | Description\n| :-         | :-           | :-\n| `config`   | **required** | general configuration about the database and the admin\n| `settings` | **required** | settings for all tables, columns, relationships, views, etc.\n| `users`    | **required** | list of admin users\n| `custom`   | _optional_   | custom views, event hooks, and static files to be loaded with the admin\n\nFor simplicity all of the [examples] are storing all of their configuration in JSON files. However, only the `settings` configuration is required to be a JSON file stored on your server, because the admin may add new default table entries in it whenever you add a new table to your database. On the contrary the `config` and the `users` configuration, potentially containing sensitive passwords in plain text, can be loaded during the initialization of your server from an external storage and fed into the admin middleware as an object.\n\n---\n\n## Configuration: `config`\n\n```json\n{\n  \"mysql\": { // or \"pg\" or \"sqlite\"\n    \"database\": \"...\",\n    \"user\": \"...\",\n    \"password\": \"...\"\n    // \"schema\": \"... pg only\"\n  },\n  \"admin\": {\n    \"settings\": \"/absolute/path/to/settings.json\"\n  }\n}\n```\n\n### Database Connection\n\nThe `mysql`, `pg` and `sqlite` key instruct the admin to load the relevant database driver, either: [mysql], [pg] or [sqlite3]. That driver needs to be set as a dependency in your package.json file and installed:\n\n- **mysql** - accepts any [connection option][mysql-connection] for the `mysql` module\n- **pg** - accepts any [connection option][pg-connection] for the `pg` module\n- **sqlite** - accepts only a `database` option containing an absolute path to a database file to use\n\nAt the minimum you need:\n\n- **mysql** or **pg** or **sqlite** - database connection options\n  - **database** - name of the database to use for this connection (or absolute path to a database file for SQLite)\n  - **user** - database user to authenticate with (not used wih SQLite)\n  - **password** - password for that database user (not used wih SQLite)\n\nOutside of the regular connection options for PostgreSQL the admin also accepts a `schema` name to use for that database. It defaults to `public`.\n\n### Admin Configuration\n\nThe `admin` config key is used to customize various aspects of the admin app:\n\n- **settings** _(**required**)_ - the absolute path to the `settings.json` file\n\n- **layouts** _(default: true)_ - toggle the layouts selection button inside the header\n\n- **themes** _(default: true)_ - toggle the themes selection button inside the header\n\n- **languages** _(default: true)_ - toggle the languages selection button inside the header\n\n- **favicon** - absolute path to a folder containing a `favicon.ico` file. When this path is set the admin will serve your custom `favicon.ico` file instead of the default one\n\n- **footer** - accepts `{text: '..', url: '..'}` object to customize the default footer text and URL. Set it to empty `{}` object to hide the footer\n\n- **locale** - absolute path to a locale.json file. Copy/paste some of the existing [locale] files and customize it to your liking. The name of your file will be used as either a new locale or it will override an existing one with the same name. The locale that you set through this option will be selected and used by default for new users\n\n- **root** _(default: '')_ - path prefix for the admin instance. Required only when you mount the admin under a path prefix with `app.use('/prefix', admin({}))`. Also take a look at the [hosting examples](#hosting-multiple-admins)\n\n- **upload** _(default: 'public/upload')_ - absolute path to the upload location for columns with control type set to `file: true`. By default the admin will upload and store such files inside `your-project/node_modules/express-admin/public/upload`\n\n- **session** - accepts any of the configuration options for the underlying [session] middleware. The default configuration is: `{name: 'express-admin', secret: 'very secret', saveUninitialized: true, resave: true}`\n\n- **readonly** _(default: false)_ - when set to `true` the authentication, CSRF, session logout and database updates are off. The readonly mode is useful while configuring the admin using the `settings.json` file. Additionally a watcher can be set to reload your server on changes being made to the `settings.json` file. Don't forget to turn off the readonly mode when you are done editing the `settings.json` file!\n\n- **debug** _(default: false)_ - set this to `true` to print the underlying SQL queries that the admin performs. Additionally a logging middleware, such as [morgan], can be mounted before the admin to log the underlying HTTP requests as well\n\n---\n\n## Configuration: `settings`\n\nAll settings related to the default Express Admin views are set inside the `settings.json` file, which is automatically generated with default values at first start up. Every time you add a new table to your database a default configuration object will be added for it to your `settings.json` file at startup.\n\n### table\n\nThe `settings.json` file contains a list of objects representing tables in your database:\n\n```json\n{\n  \"table_name\": {\n    \"slug\": \"unique-slug\",\n    \"table\": {\n      \"name\": \"table_name\",\n      \"pk\": \"pk_name\",\n      \"verbose\": \"Verbose Name\"\n      // \"schema\": \"name\" // pg: set specific schema for this table only\n    },\n    \"columns\": [\n      {...}, // see column definition below\n      // { \"manyToMany\" ... } // see 'Many to Many' documentation\n    ],\n    \"mainview\": {\n      \"show\": true\n    },\n    \"listview\": {\n      \"order\": {\n        \"column_name1\": \"asc\",\n        \"column_name2\": \"desc\"\n      },\n      \"page\": 25,\n      \"filter\": [\"column_name1\", \"column_name2\" ...]\n    },\n    \"editview\": {\n      \"readonly\": false,\n      // \"manyToOne\": { ... }, // see 'Many to One' documentation\n      // \"oneToOne\": { ... } // see 'One to One' documentation\n    }\n  }\n}\n```\n\n- **slug** - unique slug among all other tables\n- **table** - table settings\n  - **name** - the table name in your database _(typically you won't change this)_\n  - **pk** - the table's primary key _(it will be set automatically)_\n  - **verbose** - user friendly name to use for this table inside the admin\n- **columns** - array of all columns found in this table _(see below)_\n- **mainview** - settings about the mainview _(where tables are listed)_\n  - **show** - toggle the table visibility inside the table list. Typically you want to hide tables that will be edited as [inlines](#relationships-many-to-one) of other tables, or tables that are used as links for [many to many](#relationships-many-to-many) relationships\n- **listview** - settings about the listview _(where the table records are listed)_\n  - **order** - default record order, either ascending or descending _(defaults to ascending)_\n  - **page** - how many records to show per page _(defaults to 25)_\n  - **filter** - list of column names to enable as filtering options inside the filter widget\n- **editview** - settings about the editview _(where the record is being shown and edited)_\n  - **readonly** - to make the table non editable set this flag to true, in that case the save and delete buttons below the record won't be rendered\n  - **manyToOne** - set inline tables to be edited along with this one _(see [many to one](#relationships-many-to-one) documentation)_\n  - **oneToOne** - set inline tables to be edited along with this one _(see [one to one](#relationships-one-to-one) documentation)_\n\nTo re-order how the tables appear inside the mainview copy/paste the entire table object and move it to another place inside the `settings.json` file.\n\n### column\n\nEach table object contains a list of colum objects:\n\n```json\n{\n  \"verbose\": \"Verbose Name\",\n  \"name\": \"column_name\",\n  \"control\": {\n    \"text\": true\n  },\n  \"type\": \"varchar(45)\",\n  \"allowNull\": false,\n  \"defaultValue\": null,\n  \"listview\": {\n    \"show\": true\n  },\n  \"editview\": {\n    \"show\": true\n  },\n  // \"oneToMany\": { ... }, // see 'One to Many' documentation\n}\n```\n\n- **verbose** - user friendly name to use for this column inside the admin\n- **name** - the column name in your database _(typically you won't change this)_\n- **control** - one of these:\n\n  ```js\n  {\"text\": true} // input type=\"text\"\n  {\"textarea\": true} // textarea\n  {\"textarea\": true, \"editor\": \"some-class\"} // text editor (see customization section)\n  {\"number\": true} // input type=\"number\"\n  {\"date\": true} // datepicker\n  {\"time\": true} // timepicker\n  {\"datetime\": true} // datetimepicker\n  {\"year\": true} // yearpicker\n  {\"file\": true} // input type=\"file\" (uploads to file system)\n  {\"file\": true, \"binary\": true} // input type=\"file\" (uploads to blob|bytea fields)\n  {\"radio\": true, \"options\": [\"True\",\"False\"]} // input type=\"radio\"\n  {\"select\": true} // select single (used for one-to-many relationships)\n  {\"select\": true, \"multiple\": true} // select multiple (used for many-to-many relationships)\n  {\"select\": true, \"options\": [\"value\u0026text\",{\"value\":\"text\"}]} // select with static options\n  ```\n\n- **type** - the column data type in your database _(typically you won't change this)_\n- **allowNull** - allowed to be null inside the database or not\n- **defaultValue** - currently not used\n- **listview** - settings about the listview _(where the table records are listed)_\n  - **show** - toggle the column visibility inside the listview. Typically you want to see only colums that contain short and meaningful information describing the whole record clearly. Primary key columns and columns that contain large amount of text typically should be hidden in this view\n- **editview** - settings about the editview _(where the record is being shown and edited)_\n  - **show** - toggle the column visibility inside the listview\u003cbr /\u003e\n  **`All auto increment columns should be hidden!`**\u003cbr /\u003e\n  **`Foreign keys for inline tables should be hidden!`**\u003cbr /\u003e\n  Columns that are not allowed to be `null` inside the database cannot be hidden as this will result in a database error when trying to insert or update the record\n- **oneToMany** - configure one to many relationship _(see [one to many](#relationships-one-to-many) documentation)_\n\nAdditionally a column entry can be added manually inside the `columns` array for configuring [many to many](#relationships-many-to-many) relationships.\n\nTo re-order how the columns appear inside the listview and the editview copy/paste the entire column object and move it to another place inside the `columns` array.\n\n**[Control Types Example][example-control-types]**\n\n---\n\n## Configuration: `users`\n\nThe users configuration accepts a list of unique keys identifying the admin users:\n\n```json\n{\n  \"admin\": {\n    \"name\": \"admin\",\n    \"pass\": \"1234abCD\"\n  }\n}\n```\n\n- **\"unique key name\"**\n  - **name** - user name to login with\n  - **pass** - user password to login with\n\nEach user get access to the entirety of your admin instance. There are no roles and different levels of access.\n\nFor increased security you may want to load the user password from external storage dynamically and feed that into the admin middleware on startup.\n\n---\n\n## Configuration: `custom`\n\nThe custom configuration can be used to extend the admin with additional static files, endpoints on the backend, custom views rendered inside the admin, and pre/post save event hooks.\n\nYou can arrange your custom resources any way you want. The custom config should contain uniquely named objects containing any of the supported custom configuration keys:\n\n```json\n{\n  \"Unique Name\": {\n    \"app\": {\n      \"path\": \"/absolute/path/to/custom/app.js\",\n      \"slug\": \"unique-slug\",\n      \"verbose\": \"Verbose Name\",\n      \"mainview\": {\n        \"show\": true\n      }\n    },\n    \"public\": {\n      \"external\": {\n        \"css\": [\n          \"https://absolute/url/external.css\"\n        ],\n        \"js\": [\n          \"https://absolute/url/external.js\"\n        ]\n      },\n      \"local\": {\n        \"path\": \"/absolute/path/to/custom/public/folder\",\n        \"css\": [\n          \"/relative/to/above/global.css\"\n        ],\n        \"js\": [\n          \"/relative/to/above/global.js\"\n        ]\n      }\n    },\n    \"events\": \"/absolute/path/to/custom/events.js\"\n  }\n}\n```\n\n- **app** - Epress.js application (middleware) _(see [custom views](#custom-views) documentation)_\n  - **path** - absolute path to the file to mount\n  - **slug** - prefix for all routes in this custom app\n  - **verbose** - user friendly name to show inside the mainview\n  - **mainview** - settings about the mainview\n    - **show** - toggle the custom view visibility inside the mainview (inside the custom views list below the table list)\n- **public** - custom static files to include inside the `\u003chead\u003e` tag _(see [custom static files](#custom-static-files) documentation)_\n  - **external** - external files\n    - **css** - list of css files to be included\n    - **js** - list of js files to be included\n  - **local** - local files\n    - **path** - absolute path to the static files location\n    - **css** - list of css files to be included\n    - **js** - list of js files to be included\n- **events** - path to file containing event hooks _(see [event hooks](#custom-event-hooks) documentation)_\n\n**[Custom View Example][example-custom-views]**\n\n---\n\n## Relationships\n\n## Relationships: One to Many\n\n![img-one-to-many]\n\n1. Find the table that you want to configure inside the `settings.json` file\n2. Find the foreign key column that you want to use for the relation\n3. Copy/paste the following `oneToMany` object into the column settings and configure it\n4. Change the control type of the column to `select`\n\n```json\n\"control\": {\n  \"select\": true\n},\n\"oneToMany\": {\n  \"table\": \"user\",\n  \"pk\": \"id\",\n  \"columns\": [\n    \"firstname\",\n    \"lastname\"\n  ]\n}\n```\n\n\u003e The `oneToMany` key can contain a `schema` name to use for the relation table (PostgreSQL only)\n\n- **oneToMany** - configuration about the table that this foreign key references\n  - **table** - name of the table that is being referenced\n  - **pk** - name of the referenced table primary key column _(can be array as well, see [compound primary key](#compound-primary-key) documentation)_\n  - **columns** - array of columns to select from the referenced table and use that as a label inside the select box _(space delimited)_\n\n**[One To Many Example][example-one-to-many]**\n\n---\n\n## Relationships: Many to Many\n\n![img-many-to-many]\n\n1. Find the table that you want to configure inside the `settings.json` file\n2. Copy/paste the following object inside the `columns` array and configure it\n\n```json\n{\n  \"verbose\": \"Recipe Types\",\n  \"name\": \"recipe_type\",\n  \"control\": {\n    \"select\": true,\n    \"multiple\": true\n  },\n  \"type\": \"int(11)\",\n  \"allowNull\": false,\n  \"listview\": {\n    \"show\": false\n  },\n  \"editview\": {\n    \"show\": true\n  },\n  \"manyToMany\": {\n    \"link\": {\n      \"table\": \"recipe_has_recipe_types\",\n      \"parentPk\": \"recipe_id\",\n      \"childPk\": \"recipe_type_id\"\n    },\n    \"ref\": {\n      \"table\": \"recipe_type\",\n      \"pk\": \"id\",\n      \"columns\": [\n        \"title\"\n      ]\n    }\n  }\n}\n```\n\n\u003e The `link` and `ref` keys can contain a `schema` name to use for the relation table (PostgreSQL only)\n\n- **verbose** - user friendly name to use for this column inside the admin\n- **name** - some arbitrary name but it has to be unique among all other columns in this table\n- **control** - the control type have to be a multi select\n- **type** - leave this as it is\n- **allowNull** - allowed to create record in this table without selecting any item from the referenced one, or not\n- **listview** - settings about the listview _(where the table records are listed)_\n  - **show** - toggle the column visibility inside the listview\n- **editview** - settings about the editview _(where the record is being shown and edited)_\n  - **show** - toggle the column visibility inside the listview\n- **manyToMany** - configuration about the many to many relationship\n  - **link** - configuration about the table that links this one and the referenced one\n    - **table** - name of the table that acts as a link\n    - **parentPk** - name of the primary key of the parent table _(can be array as well)_\n    - **childPk** - name of the primary key of the child table _(can be array as well)_\n  - **ref** - configuration about the referenced table\n    - **table** - name of the table that is being referenced\n    - **pk** - name of the referenced table primary key column _(can be array as well)_\n    - **columns** - array of columns to select from the referenced table and use that as a label inside the select box _(space delimited)_\n\n\u003e The `parentPk` and `childPk` keys of the `link` table can be array as well.\u003cbr /\u003e\n\u003e The `pk` key of the `ref` table can be array as well.\u003cbr /\u003e\n\u003e See [compound primary key](#compound-primary-key) documentation.\n\n**[Many To Many Example][example-many-to-many]**\n\n---\n\n## Relationships: Many to One\n\n![img-many-to-one]\n\n1. Find the table that you want to configure inside the `settings.json` file\n2. Copy/paste the following object inside the `editview` object and configure it\n\n```json\n\"manyToOne\": {\n  \"repair\": \"car_id\",\n  \"driver\": \"car_id\"\n}\n```\n\n- **manyToOne** - configuration about the tables that will be included and edited as inline record\n  - **table:fk** - each item in this object is a table name and its foreign key column that is referencing the parent table _(or array of foreign keys, see [compound primary key](#compound-primary-key) documentation)_\n\n**[Many To One Example][example-many-to-one]**\n\n---\n\n## Relationships: One to One\n\n![img-one-to-one]\n\n1. Find the table that you want to configure inside the `settings.json` file\n2. Copy/paste the following object inside the `editview` object and configure it\n\n```json\n\"oneToOne\": {\n  \"address\": \"user_id\",\n  \"phone\": \"user_id\"\n}\n```\n\n- **oneToOne** - configuration about the tables that will be included and edited as inline record\n  - **table:fk** - each item in this object is a table name and its foreign key column that is referencing the parent table _(or array of foreign keys, see [compound primary key](#compound-primary-key) documentation)_\n\n**[One To One Example][example-one-to-one]**\n\n---\n\n## Compound Primary Key\n\n![img-compound-primary-key]\n\nAny table in `settings.json` can have multiple primary keys specified:\n\n```json\n{\n  \"table\": {\n    \"name\": \"tbl\",\n    \"pk\": [\n      \"id1\",\n      \"id2\"\n    ],\n    \"verbose\": \"tbl\"\n  }\n}\n```\n\n### Compound: One to Many\n\n![img-compound-one-to-many]\n\nIn case One to Many table relationship is referenced by multiple foreign keys, the regular [One to Many](#relationships-one-to-many) configuration cannot be used, as it expects to be put inside an existing column inside the `settings.json` file.\n\nTherefore an additional column entry have to be added to the `columns` array, similar to how [Many to Many](#relationships-many-to-many) relationship is being configured.\n\nThe `fk` key specifies the foreign keys in this table that are referencing the other one:\n\n```json\n{\n  \"verbose\": \"otm\",\n  \"name\": \"otm\",\n  \"control\": {\n    \"select\": true\n  },\n  \"type\": \"varchar(45)\",\n  \"allowNull\": false,\n  \"listview\": {\n    \"show\": true\n  },\n  \"editview\": {\n    \"show\": true\n  },\n  \"fk\": [\n    \"otm_id1\",\n    \"otm_id2\"\n  ],\n  \"oneToMany\": {\n    \"table\": \"otm\",\n    \"pk\": [\n      \"id1\",\n      \"id2\"\n    ],\n    \"columns\": [\n      \"name\"\n    ]\n  }\n}\n```\n\n### Compound: Many to Many\n\n![img-compound-many-to-many]\n\nIn case tables with multiple primary keys are part of a Many to Many table relationship, the regular [Many to Many](#relationships-many-to-many) setting is used, but additionally the `parentPk` and `childPk` keys inside the `link` table, and the `pk` key inside the `ref` table, can be set to an array of foreign and primary keys respectively to accommodate that design:\n\n```json\n{\n  \"verbose\": \"mtm\",\n  \"name\": \"mtm\",\n  \"control\": {\n    \"select\": true,\n    \"multiple\": true\n  },\n  \"type\": \"varchar(45)\",\n  \"allowNull\": false,\n  \"listview\": {\n    \"show\": true\n  },\n  \"editview\": {\n    \"show\": true\n  },\n  \"manyToMany\": {\n    \"link\": {\n      \"table\": \"tbl_has_mtm\",\n      \"parentPk\": [\n        \"tbl_id1\",\n        \"tbl_id2\"\n      ],\n      \"childPk\": [\n        \"mtm_id1\",\n        \"mtm_id2\"\n      ]\n    },\n    \"ref\": {\n      \"table\": \"mtm\",\n      \"pk\": [\n        \"id1\",\n        \"id2\"\n      ],\n      \"columns\": [\n        \"name\"\n      ]\n    }\n  }\n}\n```\n\n### Compound: Many to One\n\n![img-compound-many-to-one]\n\nSimilar to the regular [Many to One](#relationships-many-to-one) configuration, but additionally the value for each table listed there can be set to an array of foreign keys referencing this table.\n\n```json\n\"manyToOne\": {\n  \"mto\": [\n    \"tbl_id1\",\n    \"tbl_id2\"\n  ]\n}\n```\n\n### Compound: One to One\n\nSimilar to the regular [One to One](#relationships-one-to-one) configuration, but additionally the value for each table listed there can be set to an array of foreign keys referencing this table.\n\n```json\n\"oneToOne\": {\n  \"oto\": [\n    \"tbl_id1\",\n    \"tbl_id2\"\n  ]\n}\n```\n\n---\n\n## Filter\n\nColumns can be enabled to be available for filtering inside the listview by listing them inside the `filter` list for that table's `listview` config:\n\n```json\n\"listview\": {\n  \"order\": {},\n  \"page\": 5,\n  \"filter\": [\n    \"item_id\",\n    \"user_id\",\n    \"cache\",\n    \"date\",\n    \"deleted\",\n    \"deleted_at\"\n  ]\n}\n```\n\nAll column data types and control types will be picked from the `columns` definition for that table. For example, a foreign key identifier configured as one-to-many relationship will be rendered as select box to allow the user to pick a value from. Similarly a many-to-many column entry can be listed by its `name` key and it will be rendered as multiple select box allowing you to pick multiple values from the referenced table to filter by. Date and time pickers will be rendered as pairs of two controls to allow you to specify date and time ranges if needed.\n\n**[Listview Filter Example][example-listview-filter]** _(click on the little funnel icon next to the page header)_\n\n---\n\n## Customization\n\n## Custom: Static Files\n\nCustom static files can be included at the end of the `\u003chead\u003e` tag of the base template:\n\n```json\n{\n  \"something\": {\n    \"public\": {\n      \"external\": {\n        \"js\": [\n          \"https://cdn.ckeditor.com/4.4.2/standard/ckeditor.js\"\n        ]\n      },\n      \"local\": {\n        \"path\": \"/absolute/path/to/folder\",\n        \"css\": [\n          \"/some.css\"\n        ],\n        \"js\": [\n          \"/some.js\"\n        ]\n      }\n    }\n  }\n}\n```\n\nTake a look at the [examples] repository.\n\n### CKEditor\n\nOne good example of the use of custom files could be extending the admin with a rich text editor.\n\nFind the column that you want to configure in `settings.json` and set its control type to a `textarea` and additionally set a CSS class name to use for the rich text editor:\n\n```json\n\"control\": {\n  \"textarea\": true,\n  \"editor\": \"class-name\"\n}\n```\n\nThen inside the `custom.json` file specify the static files to include:\n\n```json\n{\n  \"Rich Text Editors\": {\n    \"public\": {\n      \"external\": {\n        \"js\": [\n          \"https://cdn.ckeditor.com/4.4.2/standard/ckeditor.js\"\n        ]\n      },\n      \"local\": {\n        \"path\": \"/absolute/path/to/folder\",\n        \"js\": [\n          \"/relative/path/to/the/above/init.js\"\n        ]\n      }\n    }\n  }\n}\n```\n\nCKEditor v4.4.2 can be initialized like this:\n\n```js\n$(function () {\n  if (typeof CKEDITOR !== 'undefined') {\n    CKEDITOR.replaceAll(function (textarea, config) {\n      // exclude textareas that are inside hidden inline rows\n      if ($(textarea).parents('tr').hasClass('blank')) return false\n      // textareas with this class name will get the default configuration\n      if (textarea.className.includes('class-name')) return true\n      // all other textareas won't be initialized as ckeditors\n      return false\n    })\n  }\n})\n\n// executed each time an inline is added\nfunction onAddInline (rows) {\n  if (typeof CKEDITOR !== 'undefined') {\n    // for each of the new rows containing textareas\n    $('textarea', rows).each(function (index) {\n      // get the DOM instance\n      var textarea = $(this)[0]\n      // textareas with this class name will get the default configuration\n      if (textarea.className.includes('class-name')) return CKEDITOR.replace(textarea)\n      // all other textareas won't be initialized as ckeditors\n      return false\n    })\n  }\n}\n```\n\nNote that jQuery `$` is loaded globally for the entire admin.\n\nThe `class-name` is the same class name that we specified for that column inside the `settings.json` file.\n\nThe `CKEDITOR.replaceAll` method loops throgh all textareas available on the page and filters them out to only those that needs to be initialized as CKEditors. Inline records for Many to One and One to One relationships has a hidden blank row used as a template whenever the user tries to add new inline record to the page. Any textarea inside that `blank` row have to be excluded.\n\nThe hidden textareas are initialized after the user clicks on the link to add a new inline record. The `onAddInline` is an event like global function that is called each time an inline record is appended to the list of inline records. The `rows` parameters contains all table rows that has been added. Again we loop through all of them and initialize only those textareas that have the class we specified in `settings.json`\n\n**[CKEditor Example][example-complex-inline]**\n\n---\n\n## Custom: Views\n\nThe custom view config is configured by the `app` key:\n\n```json\n{\n  \"My Awesome View\": {\n    \"app\": {\n      \"path\": \"/absolute/path/to/app.js\",\n      \"slug\": \"hi\",\n      \"verbose\": \"Basic View\",\n      \"mainview\": {\n        \"show\": true\n      }\n    }\n  }\n}\n```\n\nThe `app.js` file contains an Express.js middleware:\n\n```js\nvar express = require('express')\nvar app = module.exports = express()\nvar path = require('path')\n\napp.set('views', __dirname)\n\napp.get('/hi', (req, res, next) =\u003e {\n  // variable that will be available inside your template\n  res.locals.hello = 'Hi'\n  // realtive path from the admin's view folder to your custom folder\n  var relative = path.relative(res.locals._admin.views, app.get('views'))\n  // the content partial holds the main content of the page\n  res.locals.partials = {\n    // path to your hello.html template\n    content: path.join(relative, 'hello')\n  }\n  // continue so that the admin can render the entire page\n  next()\n})\n```\n\nThe `hello.html` template is an HTML file that contains [Hogan.js] ([mustache.js]) variables:\n\n```html\n\u003cp\u003e{{hello}}, how are you?\u003c/p\u003e\n```\n\nThe `res.locals` object contains all of the template variables that will be used across various partials to render the entirety of the admin UI.\n\nOne additional variable called `res.locals._admin` exposes the admin internals to your route. The contents of this variable are not meant to be rendered and are there for internal use by your custom routes.\n\nFor example, the `res.locals._admin.db.client` holds a reference to the underlying database client wrapper that the admin uses internally:\n\n```js\napp.get('/hi', (req, res, next) =\u003e {\n  var client = res.locals._admin.db.client\n  // do some queries\n  client.query('... sql ...', (err, result) =\u003e {\n    // do something with result data\n  })\n}\n```\n\nTo find more about the available data there you can put a breakpoint inside your custom route handler and inspect it with a debugger.\n\nAlso have a look at the [examples] repository.\n\n**[Custom View Example][example-custom-views]**\n\n---\n\n## Custom: Event Hooks\n\nThe supported event hooks are:\n\n- **[preSave](#custom-presave)** - before a record is saved\n- **[postSave](#custom-postsave)** - after a record was saved\n- **[preList](#custom-prelist)** - before the listview is rendered\n\nThe event hooks config is configured by the `events` key:\n\n```json\n{\n  \"My Awesome Event Hooks\": {\n    \"events\": \"/absolute/path/to/event/handlers.js\"\n  }\n}\n```\n\nThe event handlers are similar to Express.js middlewares, but they have one additional `args` parameter:\n\n```js\nexports.preSave = (req, res, args, next) =\u003e {\n  // do something\n  next()\n}\nexports.postSave = (req, res, args, next) =\u003e {\n  // do something\n  next()\n}\nexports.postList = (req, res, args, next) =\u003e {\n  // do something\n  next()\n}\n```\n\nYou can put a breakpoint inside any of your event hook handlers and inspect the available handler parameters with a debugger.\n\nAlso have a look at the [examples] repository.\n\n## Custom: `preSave`\n\nThe `args` parameter contains:\n\n- **action** - query operation: `insert`, `update` or `remove`\n- **name** - the table name for which this operation was initiated for\n- **slug** - the slug of that table\n- **data** - data submitted via POST request or returned from the database\n  - **view** - this table's data (the one currently shown inside the _editview_)\n  - **oneToOne | manyToOne** - inline tables data\n      ```js\n      \"table's name\": {\n        \"records\": [\n          \"columns\": {\"column's name\": \"column's value\", ...},\n          \"insert|update|remove\": \"true\" // only for inline records\n        ]\n      }\n      ```\n- **upath** - absolute path to the upload folder location\n- **upload** - list of files to be uploaded submitted via POST request\n- **db** - database connection instance\n\n### `preSave` - set created_at and updated_at fields\n\nIn this example we are updating the `created_at` and the `updated_at` fileds for a table called `user`:\n\n```js\nvar moment = require('moment')\n\nexports.preSave = (req, res, args, next) =\u003e {\n  if (args.name === 'user') {\n    var now = moment(new Date()).format('YYYY-MM-DD hh:mm:ss')\n    var record = args.data.view.user.records[0].columns\n    if (args.action === 'insert') {\n      record.created_at = now\n      record.updated_at = now\n    }\n    else if (args.action === 'update') {\n      record.updated_at = now\n    }\n  }\n  next()\n}\n```\n\nThe `created_at` and the `updated_at` columns have to be hidden inside the editview because they will be updated internally by your event hook. Set `show: false` for the `editview` key for those columns inside the `settings.json` file.\n\n### `preSave` - generate hash identifier\n\nIn this example we are generating a hash `id` for a table called `cars`. That table view also contains `manyToOne` inline tables to be edited along with it that also needs their `id` generated:\n\n```js\nvar shortid = require('shortid')\n\nexports.preSave = (req, res, args, next) =\u003e {\n  if (args.name == 'car') {\n    if (args.action == 'insert') {\n      var table = args.name\n      var record = args.data.view[table].records[0].columns\n      record.id = shortid.generate()\n    }\n    for (var table in args.data.manyToOne) {\n      var inline = args.data.manyToOne[table]\n      if (!inline.records) continue\n      for (var i=0; i \u003c inline.records.length; i++) {\n        if (inline.records[i].insert != 'true') continue\n        inline.records[i].columns.id = shortid.generate()\n      }\n    }\n  }\n  next()\n}\n```\n\nAll of the `id` columns have to be hidden inside the editview because they will be generated internally by your event hook. Set `show: false` for the `editview` key for those columns inside the `settings.json` file.\n\n### `preSave` - soft delete records\n\nIn this example we are soft deleting records for a table called `purchase`. That table view also contains `manyToOne` inline tables to be edited along with it that also requires their records to be soft deleted:\n\n```js\nvar moment = require('moment')\n\nexports.preSave = (req, res, args, next) =\u003e {\n  if (args.name === 'purchase') {\n    var now = moment(new Date()).format('YYYY-MM-DD hh:mm:ss')\n    // all inline oneToOne and manyToOne records should be marked as deleted\n    for (var table in args.data.manyToOne) {\n      var inline = args.data.manyToOne[table]\n      if (!inline.records) continue\n      for (var i=0; i \u003c inline.records.length; i++) {\n        if (args.action !== 'remove' \u0026\u0026 !inline.records[i].remove) continue\n        // instead of deleting the record\n        delete inline.records[i].remove\n        // update it\n        inline.records[i].columns.deleted = true\n        inline.records[i].columns.deleted_at = now\n      }\n    }\n    // parent record\n    if (args.action == 'remove') {\n      // instead of deleting the record\n      args.action = 'update'\n      // update it\n      var record = args.data.view.purchase.records[0].columns\n      record.deleted = true\n      record.deleted_at = now\n    }\n  }\n  next()\n}\n```\n\nAll of the `deleted` and `deleted_at` columns have to be hidden inside the editview because they will be managed by your event hook. Set `show: false` for the `editview` key for those columns inside the `settings.json` file.\n\n---\n\n## Custom: `postSave`\n\nThe `args` parameter contains:\n\n- **action** - query operation: `insert`, `update` or `remove`\n- **name** - the table name for which this operation was initiated for\n- **slug** - the slug of that table\n- **data** - data submitted via POST request or returned from the database\n  - **view** - this table's data (the one currently shown inside the _editview_)\n  - **oneToOne | manyToOne** - inline tables data\n    ```js\n    \"table's name\": {\n        \"records\": [\n            \"columns\": {\"column's name\": \"column's value\", ...},\n            \"insert|update|remove\": \"true\" // only for inline records\n        ]\n    }\n    ```\n- **upath** - absolute path to the upload folder location\n- **upload** - list of files to be uploaded submitted via POST request\n- **db** - database connection instance\n\n### `postSave` - upload files to a third party server\n\n- in this example our table will be called `item`\n- the item's table `image`'s column control type should be set to `file:true` in `settings.json`\n- use the code below to upload the image, after the record is saved\n\nIn this example we are uploading an image to a third-party service for a table called `item`:\n\n```js\nvar cloudinary = require('cloudinary')\nvar fs = require('fs')\nvar path = require('path')\ncloudinary.config({cloud_name: '...', api_key: '...', api_secret: '...'})\n\nexports.postSave = (req, res, args, next) =\u003e {\n  if (args.name === 'item') {\n    // file upload control data\n    var image = args.upload.view.item.records[0].columns.image\n    // in case file is chosen through the file input control\n    if (image.name) {\n      // file name of the image already uploaded to the upload folder\n      var fname = args.data.view.item.records[0].columns.image\n      // upload\n      var fpath = path.join(args.upath, fname)\n      cloudinary.uploader.upload(fpath, (result) =\u003e {\n        console.log(result)\n        next()\n      })\n    }\n    else next()\n  }\n  else next()\n}\n```\n\nThe `image` column needs to have its control type set to `file: true` inside the `settings.json` file.\n\n---\n\n## Custom: `preList`\n\nThe `args` parameter contains:\n\n- **name** - the table name for which this operation was initiated for\n- **slug** - the slug of that table\n- **filter** - filter data submitted via POST request\n  - **columns** - list of columns (and their values) to filter by\n  - **direction** - sort order direction\n  - **order** - column names to order by\n  - **or** - `true|false` whether to use logical _or_ or not\n- **statements** - sql query strings partials\n  - **columns** - columns to select\n  - **table** - table to select from\n  - **join** - join statements\n  - **where** - where statements\n  - **group** - group by statements\n  - **order** - order by statements\n  - **from** - limit from number\n  - **to** - limit to number\n- **db** - database connection instance\n\n### `preList` - hide soft deleted records by default\n\nHave a look at the `preSave` hook example about soft deleted records.\n\n```js\nexports.preList = (req, res, args, next) =\u003e {\n  if (args.name === 'purchase') {\n    // check if we are using a listview filter\n    // and we want to see soft deleted records\n    var filter = args.filter.columns\n    if (filter \u0026\u0026 (filter.deleted == '1' || filter.deleted_at \u0026\u0026 filter.deleted_at[0])) {\n      return next()\n    }\n    // otherwise hide the soft deleted records by default\n    var filter =\n      ' `purchase`.`deleted` IS NULL OR `purchase`.`deleted` = 0' +\n      ' OR `purchase`.`deleted_at` IS NULL ';\n    args.statements.where\n      ? args.statements.where += ' AND ' + filter\n      : args.statements.where = ' WHERE ' + filter\n  }\n  next()\n}\n```\n\n---\n\n## Hosting\n\nBy default all of the static assets needed by the admin will be served by the admin middleware itself. A good way to improve the performance of your admin instance is to serve only the dynamic routes with Node.js and leave the static files to be served by a reverse proxy instead.\n\n## Hosting: Nginx\n\n```nginx\n# redirect HTTP to HTTPS\nserver {\n  listen 80;\n  server_name mywebsite.com;\n  return 301 https://$host$request_uri;\n}\n# HTTPS only\nserver {\n  listen 443 ssl;\n  server_name mywebsite.com;\n\n  # (optional) you can put an additional basic auth in front of your admin\n  auth_basic 'Restricted';\n  auth_basic_user_file /absolute/path/to/.htpasswd;\n\n  access_log /var/log/nginx/mywebsite.com-access.log;\n  error_log /var/log/nginx/mywebsite.com-error.log debug;\n\n  # certificates for HTTPS\n  ssl_certificate /etc/letsencrypt/live/mywebsite.com/fullchain.pem;\n  ssl_certificate_key /etc/letsencrypt/live/mywebsite.com/privkey.pem;\n\n  # forward all requests to Node.js except for the static files below\n  location / {\n    # this is where your admin instance is listening to\n    proxy_pass http://127.0.0.1:3000/$uri$is_args$args;\n    # (optional) hide the fact that your app was built with Express.js\n    proxy_hide_header X-Powered-By;\n  }\n\n  # express-admin - static files bundled with the admin\n  location /express-admin.css {\n    root /absolute/path/to/express-admin/node_modules/express-admin/public;\n    try_files $uri =404;\n  }\n  location /express-admin.js {\n    root /absolute/path/to/express-admin/node_modules/express-admin/public;\n    try_files $uri =404;\n  }\n  location /favicon.ico {\n    root /absolute/path/to/express-admin/node_modules/express-admin/public;\n    try_files $uri =404;\n  }\n\n  # express-admin-static - third-party static files bundled with the admin\n  location /jslib/ {\n    root /absolute/path/to/express-admin/node_modules/express-admin-static;\n    try_files $uri =404;\n  }\n  location /csslib/ {\n    root /absolute/path/to/express-admin/node_modules/express-admin-static;\n    try_files $uri =404;\n  }\n  location /font/ {\n    root /absolute/path/to/express-admin/node_modules/express-admin-static;\n    try_files /csslib/fonts/$uri =404;\n  }\n  location /bootswatch/ {\n    root /absolute/path/to/express-admin/node_modules/express-admin-static;\n    try_files $uri =404;\n  }\n\n  # (optional) any custom static file that you may have\n  location /custom.css {\n    root /absolute/path/to/custom/static/files;\n    try_files $uri =404;\n  }\n  location /custom.js {\n    root /absolute/path/to/custom/static/files;\n    try_files $uri =404;\n  }\n}\n```\n\n## Hosting: Multiple Admins\n\nMultiple admin instances can be served with a single Node.js server:\n\n```js\nvar express = require('express')\nvar admin = require('express-admin')\n\nexpress()\n  .use('/admin1', admin({\n    config: require('/path1/config.json'),\n    settings: require('/path1/settings.json'),\n    users: require('/path1/users.json'),\n    custom: require('/path1/custom.json'),\n  }))\n  .use('/admin2', admin({\n    config: require('/path2/config.json'),\n    settings: require('/path2/settings.json'),\n    users: require('/path2/users.json'),\n    custom: require('/path2/custom.json'),\n  }))\n  .use('/admin3', admin({\n    config: require('/path3/config.json'),\n    settings: require('/path3/settings.json'),\n    users: require('/path3/users.json'),\n    custom: require('/path3/custom.json'),\n  }))\n  .listen(3000)\n```\n\nIn case you are serving them directly with Node.js then you have to set the `root` prefix for each admin instance inside the `config.json` file.\n\nHowever, in case you are using Nginx on top of Node.js to route the traffic, you can setup different sub domains for each admin instance and route the traffic to the correct path prefix:\n\n```nginx\n# map the sub domain being used to the path prefix for that admin instance\nmap $http_host $admin_prefix {\n  admin1.mywebsite.com   admin1;\n  admin2.mywebsite.com   admin2;\n  admin3.mywebsite.com   admin3;\n}\n```\n\nand then update the above Nginx configuration by prepending the `$admin_prefix` variable to the path:\n\n```nginx\nlocation / {\n  # route to the appropriate admin prefix based on the sub domain being used\n  proxy_pass http://127.0.0.1:3000/$admin_prefix$uri$is_args$args;\n  # (optional) hide the fact that your app was built with Express.js\n  proxy_hide_header X-Powered-By;\n}\n```\n\nIn that case there is no need to set the `root` configuration for the admin inside the `config.json` file because the routing will be done in Nginx, and for the Node.js (Express.js) server it will look like as if that was served on the default root `/` path.\n\n---\n\n  [npm-version]: https://img.shields.io/npm/v/express-admin.svg?style=flat-square (NPM Version)\n  [snyk-vulnerabilities]: https://img.shields.io/snyk/vulnerabilities/npm/express-admin.svg?style=flat-square (Vulnerabilities)\n  [screenshot]: https://i.imgur.com/6wFggqg.png (Express Admin)\n\n  [npm]: https://www.npmjs.com/package/express-admin\n  [snyk]: https://snyk.io/test/npm/express-admin\n\n  [tests]: https://github.com/simov/express-admin-tests\n  [examples]: https://github.com/simov/express-admin-examples\n\n  [mysql]: https://www.npmjs.com/package/mysql\n  [pg]: https://www.npmjs.com/package/pg\n  [sqlite3]: https://www.npmjs.com/package/sqlite3\n  [mysql-connection]: https://github.com/mysqljs/mysql#connection-options\n  [pg-connection]: https://node-postgres.com/apis/client\n\n  [bootstrap]: https://getbootstrap.com/docs/3.4/\n  [bootswatch]: https://bootswatch.com/\n  [express.js]: https://expressjs.com/\n  [hogan.js]: https://twitter.github.io/hogan.js/\n  [mustache.js]: https://github.com/janl/mustache.js/\n  [jquery]: https://jquery.com/\n  [chosen]: https://harvesthq.github.io/chosen/\n  [bootstrap datepicker]: https://github.com/uxsolutions/bootstrap-datepicker\n\n  [example-one-to-many]: https://simov.github.io/express-admin/examples/one-to-many.html\n  [example-many-to-many]: https://simov.github.io/express-admin/examples/many-to-many.html\n  [example-many-to-one]: https://simov.github.io/express-admin/examples/many-to-one.html\n  [example-one-to-one]: https://simov.github.io/express-admin/examples/one-to-one.html\n  [example-control-types]: https://simov.github.io/express-admin/examples/column.html\n  [example-complex-inline]: https://simov.github.io/express-admin/examples/controls.html\n  [example-listview-filter]: https://simov.github.io/express-admin/examples/filter.html\n  [example-custom-views]: https://simov.github.io/express-admin/examples/custom-views-apps.html\n\n  [img-one-to-many]: https://simov.github.io/express-admin/images/one-to-many.png\n  [img-many-to-many]: https://simov.github.io/express-admin/images/many-to-many.png\n  [img-many-to-one]: https://simov.github.io/express-admin/images/many-to-one.png\n  [img-one-to-one]: https://simov.github.io/express-admin/images/one-to-one.png\n  [img-compound-primary-key]: https://simov.github.io/express-admin/images/compound-primary-key.png\n  [img-compound-one-to-many]: https://simov.github.io/express-admin/images/compound-one-to-many.png\n  [img-compound-many-to-many]: https://simov.github.io/express-admin/images/compound-many-to-many.png\n  [img-compound-many-to-one]: https://simov.github.io/express-admin/images/compound-many-to-one.png\n  [img-compound-one-to-one]: https://simov.github.io/express-admin/images/compound-one-to-one.png\n\n  [morgan]: https://www.npmjs.com/package/morgan\n  [session]: https://www.npmjs.com/package/express-session\n\n  [locale]: https://github.com/simov/express-admin/tree/master/config/lang\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsimov%2Fexpress-admin","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsimov%2Fexpress-admin","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsimov%2Fexpress-admin/lists"}