Skip to content

Buttons

Buttons are the primary way to wire actions into the admin UI — both in list rows ($content_button) and in the header ($action_button, $header_buttons). This page covers every button type with its JSON structure and copy-paste examples.

Common fields

Every button is a JSON object with the following fields:

FieldRequiredPurpose
button_idyesUnique ID within the plugin — referenced by the permission system ({pluginId}_{buttonId}_view/edit)
typeyesSee the list below — controls behavior and rendering
stylenodefault, primary, success, info, danger — Bootstrap color class
iconnoFont Awesome icon (without the fa- prefix, e.g. pencil, minus, plus, download)
titlenoVisible button label
support_textnoHelp text (e.g. in the delete dialog)
submit_textnoLabel of the submit button in the modal
forStatusnoVisible only when parseInt(item.status) === parseInt(btn.forStatus)
multilanguageno"1" enables language tabs in the edit modal (requires base_id/language_short on the DB table)
editable_rowsfor edit/insertArray of field definitions for the modal
buttonsfor nested buttonsNested sub-buttons (e.g. in show_module_list)
componentfor modalPath to a plugin Vue modal ({plugin}/admin/{ModalName}, no .vue) — see modal

Button types

edit — edit modal

Opens an edit modal with the fields defined in editable_rows. The record is loaded on open via /api/backend/item?table=…&id=…&language=….

json
{
  "button_id": "2",
  "type": "edit",
  "style": "default",
  "icon": "pencil",
  "title": "Edit",
  "submit_text": "Save",
  "multilanguage": "1",
  "editable_rows": [
    {"placeholder": "Title", "column": "title", "type": "text"},
    {"placeholder": "Body",  "column": "body",  "type": "ckeditor"},
    {"placeholder": "Image", "column": "image", "type": "image"}
  ]
}

On submit, a PATCH /api/backend/item goes out with the form payload. With multilanguage: "1", one record is stored per language (base_id/language_short pattern).

insert — create-new modal

Identical to edit, but without preloading a record. Lives inside the $action_button property (header action):

json
{
  "button_id": "3",
  "type": "insert",
  "style": "primary",
  "icon": "plus",
  "title": "New entry",
  "submit_text": "Save",
  "editable_rows": [
    {"placeholder": "Title", "column": "title", "type": "text"},
    {"placeholder": "Category", "column": "category", "type": "select",
     "select_value": {"table": "news_category", "value": "id", "title": "category"}}
  ]
}

Submit triggers POST /api/backend/item.

delete — delete dialog

Shows a confirmation dialog and triggers DELETE /api/backend/item on OK.

json
{
  "button_id": "1",
  "type": "delete",
  "style": "danger",
  "icon": "minus",
  "title": "Delete",
  "submit_text": "Delete",
  "support_text": "Really delete this entry?"
}

delete needs neither a column payload nor editable_rows — the row ID comes from the table and the core API deletes directly.

script — run a PHP action script

Runs a PHP script from the plugin's script/ folder without registering a custom API. The script receives id, url_rewrite, and optionally a data payload.

json
{
  "button_id": "4",
  "type": "script",
  "style": "info",
  "icon": "download",
  "title": "Export",
  "script": "export/runExport.php",
  "submit_text": "Run"
}

The script lives at _public/extensions/core/backend/{plugin}/script/export/runExport.php. The script value is a path relative to script/ — with or without subdirectories. If the script sits directly under script/runExport.php, "script": "runExport.php" is enough. The backend/actions/script endpoint loads the file dynamically and returns the response as JSON.

Script path validation

The path is filtered through a regex whitelist (/^[a-zA-Z0-9_\/\-]+\.php$/) and str_contains($script, '..'). Paths with dots (..) or invalid characters are rejected — never use user input directly as a script path.

show_module_list — sub-list with a custom row component

Opens a modal with a nested list, rendered through a Vue component (per row). Useful for 1:n relations — e.g. "edit article → article images" or "edit course → episodes".

json
{
  "button_id": "5",
  "type": "show_module_list",
  "style": "default",
  "icon": "list",
  "title": "Episodes",
  "values": {
    "table": "elearning_episodes",
    "template": "episodes.vue"
  },
  "buttons": [
    {"button_id": "6", "type": "edit", "icon": "pencil", "title": "Edit episode",
     "editable_rows": [...]},
    {"button_id": "7", "type": "delete", "icon": "minus", "title": "Delete episode"}
  ]
}

The row component episodes.vue lives at layout/episodes.vue in the plugin folder and renders a <tr> per row. It receives item, config, urlRewrite, and buttons as props.

Details on row components: Plugin Anatomy › Directory structure.

show_checklist — checkbox group

Opens a modal with a checklist that maps to a group table. Typical use case: user rights (user_groups.rights) or feature flags per entity.

json
{
  "button_id": "8",
  "type": "show_checklist",
  "style": "info",
  "icon": "check-square",
  "title": "Assign rights",
  "values": {
    "table": "user_groups",
    "column": "rights"
  }
}

Submit calls /api/backend/actions/checklist, which updates the column value (a JSON array) atomically.

show_custom_content — custom UI area

Loads an arbitrary PHP class and calls getContent(). The return value is used as the data source for a Vue template. For complex detail views (e.g. statistics, report dashboards) that don't fit a standard edit modal.

json
{
  "button_id": "9",
  "type": "show_custom_content",
  "style": "default",
  "icon": "chart-bar",
  "title": "Statistics",
  "values": {
    "class": "StatsController",
    "template": "stats.vue"
  }
}

Details in Plugin Anatomy › getContent().

No modal, no API call — just an <a href="…">:

json
{
  "button_id": "10",
  "type": "link",
  "style": "default",
  "icon": "external-link",
  "title": "Documentation",
  "url": "https://docs.example.com"
}

Opens a plugin-owned Vue modal directly — no PHP class, no editable_rows, no API roundtrip just to open it. Use this for a header action that needs a fully custom UI the standard edit/insert modals can't express (bulk operations, wizards, AI tools). It lives in $header_buttons.

json
{
  "button_id": "10",
  "type": "modal",
  "component": "language/admin/BulkTranslateModal",
  "style": "primary",
  "icon": "fa-light fa-language",
  "title": "AI Translate",
  "submit_text": "AI Translate"
}

The component value is a path relative to _public/extensions/core/backend/, without the .vue extension: {plugin}/admin/{ModalName}. NsTable.vue discovers every */admin/*.vue at build time via import.meta.glob and renders the matched component when the button is clicked. The modal therefore ships inside the plugin's own admin/ folder — not in the core theme.

The modal component lives at _public/extensions/core/backend/{plugin}/admin/{ModalName}.vue and communicates through two events:

EventEmitted by the modalEffect in the list
closeUser cancels / closesModal unmounts
doneAction finished successfullyModal unmounts and the list reloads (loadData)
vue
<!-- {plugin}/admin/{ModalName}.vue -->
<script setup>
const emit = defineEmits(['close', 'done'])
// on success → emit('done')   reloads the table
// on cancel  → emit('close')  just closes
</script>

Unlike script/edit, a modal button has no loading/success state on the button itself — it only opens the modal; all progress feedback lives inside the modal. Real example: the language plugin's BulkTranslateModal.vue (AI bulk-translation), registered as header_buttons index 1 on the translations table.

Component path, not a file path

component is the slug {plugin}/admin/{ModalName}no leading path, no .vue. A wrong slug fails silently (console.error('Plugin modal not found')) and the button does nothing.

Style colors

styleTypical use
defaultStandard actions (edit)
primaryMain action (new, save)
successPositive confirmation (activate, publish)
infoSecondary actions (export, statistics)
dangerDestructive actions (delete)

Fields in editable_rows

Every entry in the editable_rows array is an object with:

FieldRequiredPurpose
columnyesDB column name
placeholdernoLabel above the input
typeyesField type — see the table below
select_valuefor select{table, value, title} for dropdown options
select_optionsfor static selectArray: [{value, title}]
data-aifeaturenoComma-list of enabled AI features (e.g. "seo_title,translate")

Field types

typeUIExample
textSingle-line inputTitle
textareaMulti-line textShort description
ckeditorRich-text editorArticle body
codemirrorCode editor (LESS/CSS/JS, GitHub theme, LESS syntax validation)Custom CSS
selectDropdownCategory picker
imageMedia Manager triggerFeatured image
checkboxTogglePublished yes/no
numberNumeric inputPrice
date, time, datetimeNative inputsPublish date
passwordMasked inputAPI keys (stored encrypted)
colorColor pickerBadge color

Per-button permissions

For every button, admins can set view and edit per group. Permission keys follow the scheme {pluginId}_{buttonId}_view and {pluginId}_{buttonId}_edit — generated automatically, no manual maintenance.

24_2_view   # Plugin 24, button 2 (edit) visible
24_2_edit   # Plugin 24, button 2 executable
24_1_view   # Button 1 (delete) visible
24_1_edit   # Button 1 executable

Admin users (rights === "0") bypass all checks. Plugin permissions are assigned under /admin/user-management.

forStatus — conditional visibility

Show a button only when the record has a specific status:

json
{
  "button_id": "11",
  "type": "script",
  "icon": "check",
  "title": "Publish",
  "forStatus": 0,
  "script": "publish.php"
}

The button is visible only when parseInt(item.status) === 0. Useful for workflow statuses (draft → approved → archived).

Sentinel value -1: setting forStatus: -1 skips the check entirely — the button is always visible. That's the conventional way to disable a pre-existing forStatus entry without removing it. The real check in NsTable.vue looks like this:

js
if (btn.forStatus !== undefined && btn.forStatus !== -1) {
    if (parseInt(item.status) !== parseInt(btn.forStatus)) return false
}

Nested buttons

show_module_list and show_custom_content buttons can contain their own buttons array, rendered inside the sub-modal:

json
{
  "button_id": "5",
  "type": "show_module_list",
  "title": "Episodes",
  "values": {"table": "elearning_episodes", "template": "episodes.vue"},
  "buttons": [
    {"button_id": "6", "type": "edit",   "editable_rows": [...]},
    {"button_id": "7", "type": "delete"},
    {"button_id": "8", "type": "script", "script": "reorder.php", "title": "Reorder"}
  ]
}

button_ids must be unique within the plugin — including for nested buttons. Otherwise the permission keys collide. (The fact that the snippets above reuse the same IDs is a doc simplification — in a real plugin, IDs are not reused.)

Common issues

JSON escaping inside PHP heredoc

$content_button is a PHP string containing JSON. The easiest path is single quotes: '[{"button_id":"1",...}]'. With double quotes, every " needs a \ escape — error-prone. For deeply nested escapes, use a heredoc with <<<JSON.

button_id as a string, not a number

All button_id values are strings ("2", not 2). The permission system uses string keys — 2 vs. "2" leads to non-matching permissions.

editable_rows with an invalid DB column

A field with "column": "nonexistent_column" throws a SQL error on submit. The core API does not pre-validate columns.

forStatus compares via parseInt

forStatus: 0 matches both item.status === 0 and item.status === "0" (parseInt). But forStatus: "draft" matches nothing useful — parseInt("draft") === NaN. Use numeric statuses only.

Submit response handling for script

PHP scripts must call json_encode() and exit themselves so the core action returns cleanly. The response is passed through 1:1, no automatic wrapping in {success:true, ...}.

See also