Skip to content

Content Constructs

Jedes Plugin-Einstiegspunkt braucht einen Content-Construct — er bestimmt, wie das Backend-UI fuer den Eintrag gerendert wird. Es gibt drei Varianten: table, template und formonly. Diese Seite erklaert wann welche, und welche Properties in bootstrap.php dafuer zu setzen sind.

Uebersicht

ConstructUIWann?
tablePrimeVue DataTable (Liste + Edit-Modal)Standard-CRUD fuer eine DB-Tabelle — Listen, Sortierung, Suche, Edit/Delete kostenlos
templateEigene Vue-Komponente (layout/index.vue)Custom-UI ohne standardisierte Tabelle — Dashboards, Editoren, Integrations-Panels
formonlyEinzelnes Edit-Modal ohne ListeEin-Record-Konfiguration (Site-Settings, Stammdaten)

Mehrere Constructs in einem Plugin sind erlaubt. Das blog-Plugin z. B. registriert drei table-Eintraege nebeneinander (Posts, Kategorien, Standard-Seite).

Die 10 Kern-Properties

Alle Properties sind Arrays mit einem Eintrag pro Construct. In install() des Plugins werden sie parallel zueinander befuellt — Index [0] des einen Arrays gehoert zu Index [0] aller anderen.

PropertyZweck
$content_construct"table", "template" oder "formonly"
$content_tableDB-Tabelle (nur table) — bei anderen: ""
$content_titlesUI-Label (z. B. "Blog-Artikel")
$content_columnsComma-Liste der sichtbaren Spalten in der Liste (nur table)
$content_buttonZeilen-Buttons (JSON-Array, siehe Buttons)
$action_buttonHeader-Action-Button, z. B. "Neu" (JSON-Objekt)
$header_buttonsWeitere Header-Buttons (JSON-Array)
$content_replaceSpalten-Umbenennung fuer die Anzeige (JSON-Objekt)
$url_rewrites_backendURL-Slug → /admin/{slug}
$modal_editsModal-Feld-Config (JSON-Objekt)
$backend_widget_basePfad zum Widget-Einstieg (typisch core/backend/{plugin})

table — CRUD aus der Box

Das meistgenutzte Construct. Ein Plugin registriert eine DB-Tabelle und bekommt dafuer eine komplette Admin-UI: DataTable mit Pagination, Suche, Sortierung, Edit-Modal und Delete-Dialog.

Minimal-Beispiel

php
public function install()
{
    $this->url_rewrites_backend = ["my-articles"];
    $this->content_construct    = ["table"];
    $this->content_table        = ["my_articles"];
    $this->content_titles       = ["Meine Artikel"];
    $this->content_columns      = ["title,category,created_at"];

    // Edit-Button (Stift) + Delete-Button (Muelleimer)
    $this->content_button = ['[
        {"button_id":"2","type":"edit","style":"default","icon":"pencil","title":"Bearbeiten",
         "submit_text":"Speichern",
         "editable_rows":[
            {"placeholder":"Titel","column":"title","type":"text"},
            {"placeholder":"Text","column":"body","type":"ckeditor"}
         ],
         "multilanguage":"1"},
        {"button_id":"1","type":"delete","style":"danger","icon":"minus","title":"Loeschen",
         "submit_text":"Loeschen",
         "support_text":"Eintrag wirklich loeschen?"}
    ]'];

    // "Neuer Artikel"-Button im Header
    $this->action_button = ['{"button_id":"3","type":"insert","style":"primary",
        "icon":"plus","title":"Neuer Artikel","submit_text":"Speichern",
        "editable_rows":[
           {"placeholder":"Titel","column":"title","type":"text"},
           {"placeholder":"Text","column":"body","type":"ckeditor"}
        ]}'];

    $this->backend_widget_base = ["core/backend/my-plugin"];
    $this->content_replace     = ['{}'];
    $this->header_buttons      = ['[]'];
    $this->modal_edits         = ['{}'];
}

Das genuegt. Ohne weitere Arbeit hat dein Plugin:

  • eine Liste unter /admin/my-articles (Pagination, Suche, Sortierung per Spalte),
  • einen "Bearbeiten"-Button pro Zeile mit Modal-Formular,
  • einen "Loeschen"-Button mit Bestaetigungs-Dialog,
  • einen "Neuer Artikel"-Button im Header,
  • komplette REST-API unter /api/backend/item?table=my_articles&id=... (GET/POST/PATCH/DELETE).

Multi-Language

Setze "multilanguage":"1" im Button-JSON, damit das Edit-Modal Sprach-Tabs rendert. Voraussetzung: die DB-Tabelle hat base_id und language_short als Spalten. Die Core-Endpoints kuemmern sich automatisch um INSERT pro Sprache und Merge-Logik beim Abruf.

Details: Migrations.

content_columns vs. editable_rows

Zwei unabhaengige Konzepte:

  • content_columns (als Property von install()): Liste der Spalten, die in der DataTable sichtbar sind. Format: "col1,col2,col3" — einfacher Komma-String.
  • editable_rows (im Button-JSON): Welche Spalten bearbeitbar sind und welcher Input-Typ gerendert wird. Feldtypen: text, textarea, ckeditor, select, image, checkbox, …

Ein Plugin kann also Spalten anzeigen, die nicht editierbar sind (z. B. Timestamp created_at sichtbar in der Liste, aber im Edit-Modal ausgeblendet).

Felder-Typen fuer editable_rows

typeRendert alsBeispiel-Zusatzfelder
textEinzeiliges Input
textareaMehrzeiliger Text
ckeditorRich-Text-Editor
selectDropdown aus DB-Tabelleselect_value: {table, value, title}
imageMedia Manager-Button
checkboxToggle
number, date, timeNative Inputs

Vollstaendige Uebersicht der Feldtypen: Widgets › widget_menu-Feldtypen — die Feldtypen sind identisch zwischen Plugin-Modals und Widget-Formularen.

template — Custom-UI

Fuer alles, was nicht in eine Standard-Tabelle passt: Dashboards, visuelle Editoren, Integrations-Konfiguration. Das Plugin liefert eine eigene layout/index.vue, in der die UI frei gebaut wird.

Minimal-Beispiel

php
public function install()
{
    $this->url_rewrites_backend = ["my-dashboard"];
    $this->content_construct    = ["template"];
    $this->content_table        = [""];                   // leer bei template
    $this->content_titles       = ["Mein Dashboard"];
    $this->content_columns      = [""];
    $this->content_button       = ['[]'];
    $this->action_button        = ['[]'];
    $this->content_replace      = ['{}'];
    $this->header_buttons       = ['[]'];
    $this->modal_edits          = ['{}'];
    $this->backend_widget_base  = ["core/backend/my-plugin"];
}

Daneben braucht das Plugin:

Echte Beispiele im Repo: menueditor, design, taskmanager, updatemanager, plugin_manager, dashboard.

Daten holen

Ein Template-Plugin hat drei Wege, Daten zu laden:

  1. onLoad() liefert JSON direkt in die Page-Response — einmalig beim Seitenaufruf.
  2. Plugin-eigener API-Endpoint (backend/my-plugin) — fuer interaktive Aktionen. Siehe API-Endpunkte.
  3. Generische backend/item-API — wenn DB-Tabellen geschrieben werden muessen, ohne dass ein table-Construct aktiv ist.

Eigene Header-Buttons

Ein template-Panel baut seine Header-Leiste selbst — das deklarative $header_buttons-JSON gehoert zu table-Constructs, nicht hierher. Die Action-Buttons kommen in den Panel-Header und werden an den plugin-eigenen API-Endpoint gebunden. Das etablierte Muster (von design, seo, taskmanager genutzt) gibt jedem Button einen idle/loading/success/error-State mit Icon-Wechsel und 2-Sekunden-Auto-Reset:

vue
<header class="panel-heading">
  <p><i class="fa-light fa-magnifying-glass"></i>{{ t('Search & AI') }}</p>
  <div class="tm-header-actions">
    <button class="btn btn-primary btn-sm" :disabled="saveState === 'loading'" @click="save">
      <i :class="{
        'fal fa-save':                saveState === 'idle',
        'fal fa-circle-notch fa-spin': saveState === 'loading',
        'fal fa-check':               saveState === 'success',
        'fa-light fa-xmark':          saveState === 'error',
      }"></i>
      {{ t('Save') }}
    </button>
  </div>
</header>

<script setup lang="ts">
const config_   = useRuntimeConfig()
const apiBase   = `${config_.public.apiBase}/backend/my-plugin`
const saveState = ref<'idle' | 'loading' | 'success' | 'error'>('idle')

async function save() {
  saveState.value = 'loading'
  try {
    const res  = await fetch(apiBase, {
      method:      'POST',
      credentials: 'include',
      headers:     { 'Content-Type': 'application/json' },
      body:        JSON.stringify({ action: 'save', /* … */ }),
    })
    const data = await res.json()
    saveState.value = data.error ? 'error' : 'success'
  } catch {
    saveState.value = 'error'
  }
  setTimeout(() => { saveState.value = 'idle' }, 2000)
}
</script>

Kernpunkte:

  • apiBase aus useRuntimeConfig().public.apiBase + deinem Endpoint-Slug (backend/my-plugin) bauen — niemals /api hartkodieren.
  • Ein ref pro Button haelt seinen State; das Icon-:class bindet daran; setTimeout(…, 2000) setzt auf idle zurueck. Ein zweiter Button bekommt seinen eigenen State-Ref (z. B. hat seo saveState + genState).
  • Dispatch per action: GET ?action=load, POST { action: 'save' } — der Plugin-Endpoint routet ueber action (siehe API-Endpunkte).
  • Die Layout-Komponente bekommt content, urlRewrite und config als Props aus der Page-Response.

Braucht eine Header-Aktion einen reicheren Dialog, rendere eine beliebige Modal-Komponente direkt in deiner index.vue. Fuer den deklarativen Modal-Button auf table-Constructs siehe Buttons › modal.

Echte Beispiele: design (Save + Reset), seo (Save + Generieren).

formonly — Einzel-Formular

Fuer Konfigurationen, von denen es nur einen Datensatz gibt: Site-Einstellungen, Stammdaten, Branding-Config. Keine Liste, kein Neu-Button — nur ein Modal, das den einzigen Record bearbeitet.

php
public function install()
{
    $this->url_rewrites_backend = ["branding"];
    $this->content_construct    = ["formonly"];
    $this->content_table        = ["branding_settings"];
    $this->content_titles       = ["Branding"];
    $this->content_columns      = [""];
    $this->content_button       = ['[]'];
    $this->action_button        = ['{"button_id":"1","type":"edit","style":"primary",
        "icon":"pencil","title":"Branding bearbeiten","submit_text":"Speichern",
        "editable_rows":[
           {"placeholder":"Logo","column":"logo","type":"image"},
           {"placeholder":"Primaerfarbe","column":"primary_color","type":"text"}
        ]}'];
    $this->content_replace     = ['{}'];
    $this->header_buttons      = ['[]'];
    $this->modal_edits         = ['{}'];
    $this->backend_widget_base = ["core/backend/branding"];
}

Nutze besser template

In der Praxis wird fuer Einzel-Configs oft ein template-Construct mit eigener index.vue bevorzugt — mehr UI-Freiheit, CodeMirror-Editoren, Previews usw. formonly ist die schnellste Variante, aber vom Freiheitsgrad eingeschraenkt.

Combined: mehrere Constructs

Ein Plugin kann mehrere Einstiegspunkte haben. Das blog-Plugin registriert drei table-Constructs nebeneinander — jeder mit eigener Tabelle, eigenen Buttons und eigenem URL-Slug:

php
$this->url_rewrites_backend = ["news", "news-categories", "news-standard-page"];
$this->content_construct    = ["table", "table", "table"];
$this->content_table        = ["blog", "news_category", "news_page"];
$this->backend_widget_base  = ["core/backend/blog", "core/backend/blog", "core/backend/blog"];
$this->content_titles       = ['Blog posts', "Blog categories", "Main page for blog entries"];
$this->content_columns      = ['title', "category", "content_file"];

$this->content_button = [
    '[...]',   // Buttons fuer blog-Tabelle
    '[...]',   // Buttons fuer news_category-Tabelle
    '[...]',   // Buttons fuer news_page-Tabelle
];
// etc.

Die Admin-Sidebar zeigt dann drei getrennte Menue-Eintraege — jeder laedt seine eigene Liste unter /admin/news, /admin/news-categories, /admin/news-standard-page.

Echte Quelle: _public/extensions/core/backend/blog/bootstrap.php.

Haeufige Fehler

Ungleich lange Arrays

Alle content_*-Arrays und url_rewrites_backend muessen dieselbe Laenge haben. Fehlt z. B. fuer den dritten Construct ein Eintrag in content_button, bricht installBackend() mitten im Loop ab — nur zwei plugin_backend-Zeilen werden angelegt.

content_table bei template nicht weglassen

Auch bei "template" muss content_table einen Eintrag haben — "" reicht, aber nicht fehlen. installBackend() liest $this->content_table[$i] indexbasiert; fehlende Indizes liefern null und produzieren leere plugin_backend-Spalten plus PHP-Notice im Log.

JSON-Strings, keine PHP-Arrays

$content_button, $action_button, $header_buttons, $content_replace und $modal_edits sind JSON-Strings$this->content_button = [['button_id' => 2, ...]] funktioniert nicht, das wird als Array serialisiert. Zwei Ebenen im Kopf behalten: das aeussere PHP-Array enthaelt pro Construct einen einzelnen String; der String ist dann selbst ein JSON-Array von Button-Objekten.

Siehe auch