introduced dynamic cms content blocks, which can be predefined by adding another vue component to contentcomponents and following the catalog.js/components.js registration convention, which exists to avoid enabling the use of vue components inside content which where never designed for isolated use. contentcomponents could also be reused by traditional vue apps/components if need be; added the most used/requested dynamic content building blocks: OePersonen.js - all persons of a chosen OE; PersonKontakt.js - same but by uid; DmsListe.js - file links of a dms category; DmsDokumente.js - same but by dms_id; scan.js finds the divs inside cms contents tagged with "data-fhc-content" and teleports the actual vue component into the DOM structure of the rendered content. If there are issues/problems with the defined & allowed properties the component is skipped to avoid breaking pages with faulty configs; WIP testing edge cases with such vue content blocks and refining the feature idea in general;

This commit is contained in:
Johann Hoffmann
2026-09-01 15:31:55 +02:00
parent 09ee72bd50
commit 9f29a7798a
28 changed files with 1786 additions and 85 deletions
+277 -5
View File
@@ -39,6 +39,11 @@ class Cms extends FHCAPI_Controller
'news' => self::PERM_LOGGED,
'getNewsRowCount' => self::PERM_LOGGED,
'getNews' => self::PERM_LOGGED,
'getOePersonen' => self::PERM_LOGGED,
'getDmsKategorie' => self::PERM_LOGGED,
'getContentChilds' => self::PERM_LOGGED,
'getPerson' => self::PERM_LOGGED,
'getDmsDokumente' => self::PERM_LOGGED,
]);
@@ -55,11 +60,7 @@ class Cms extends FHCAPI_Controller
]);
}
//------------------------------------------------------------------------------------------------------------------
// Private methods
//------------------------------------------------------------------------------------------------------------------
// Public methods
@@ -203,5 +204,276 @@ class Cms extends FHCAPI_Controller
}
/**
* Lists the people assigned to an organisation unit.
* Used by the CMS content component "oe-personen".
*/
public function getOePersonen()
{
// form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('oe_kurzbz', 'Organisationseinheit', 'required');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('person/Benutzerfunktion_model', 'BenutzerfunktionModel');
$oe_kurzbz = $this->input->get('oe_kurzbz', TRUE);
$mitFoto = $this->input->get('foto', TRUE) ? true : false;
// 'oezuordnung' is the function that assigns an employee to an organisation unit.
// The model already filters datum_von and datum_bis.
$rows = $this->BenutzerfunktionModel->getBenutzerFunktionenDetailed('oezuordnung', $oe_kurzbz);
$rows = $this->getDataOrTerminateWithError($rows) ?: array();
// The model returns the aktiv flag but does not filter on it.
$rows = array_values(array_filter($rows, function ($row) {
return $row->aktiv;
}));
$uids = array();
$funktionen = array();
foreach ($rows as $row)
{
$uids[] = $row->uid;
// tbl_benutzerfunktion.bezeichnung is the free label of one assignment, for
// example "Bibliothekarin". tbl_funktion.beschreibung is the generic name.
$funktionen[$row->uid] = $row->bezeichnung ?: $row->beschreibung;
}
$this->terminateWithSuccess($this->_cmsPersonen($uids, $funktionen, $mitFoto));
}
/**
* Contact data of one person.
* Used by the CMS content component "person-block".
*/
public function getPerson()
{
// form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('uid', 'UID', 'required');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$uid = $this->input->get('uid', TRUE);
$mitFoto = $this->input->get('foto', TRUE) ? true : false;
// The function label comes from the marker, not from the database. The component
// passes it through as a property.
$personen = $this->_cmsPersonen(array($uid), array(), $mitFoto);
$this->terminateWithSuccess($personen ? current($personen) : null);
}
/**
* Lists the published documents of one DMS category.
* Used by the CMS content component "dms-liste".
*/
public function getDmsKategorie()
{
// form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('kategorie_kurzbz', 'DMS-Kategorie', 'required');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$kategorie_kurzbz = $this->input->get('kategorie_kurzbz', TRUE);
$erlaubt = $this->_dmsKategorieErlaubt($kategorie_kurzbz);
if ($erlaubt === null)
$this->terminateWithError('DMS category not found', self::ERROR_TYPE_404);
// a missing entitlement answers with success and an empty list, not with
// an error. The component sits inside a public content page. An error would raise a
// toast for every reader who is simply not in the group.
$dokumente = array();
if ($erlaubt)
{
$dokumente = $this->DmsModel->getKategorieDokumente($kategorie_kurzbz);
$dokumente = $this->getDataOrTerminateWithError($dokumente) ?: array();
}
$this->terminateWithSuccess(array(
'zugriff' => $erlaubt,
'dokumente' => $dokumente
));
}
/**
* Lists named documents, in the order the caller asked for them.
* Used by the CMS content component "dms-dokumente".
*/
public function getDmsDokumente()
{
// form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('dms_ids', 'DMS-IDs', 'required');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('content/Dms_model', 'DmsModel');
$ids = array();
foreach (explode(',', $this->input->get('dms_ids', TRUE)) as $teil)
{
$teil = trim($teil);
if (is_numeric($teil))
$ids[] = (int) $teil;
}
// An unbounded list would let one marker fetch the whole DMS.
$ids = array_slice(array_unique($ids), 0, 50);
$dokumente = $this->DmsModel->getDokumenteByIds($ids);
$dokumente = $this->getDataOrTerminateWithError($dokumente) ?: array();
// A hand picked list holds few categories, so deciding per category is cheap and
// reuses the rule of getDmsKategorie().
$erlaubte = array();
$nachId = array();
foreach ($dokumente as $dokument)
{
if (!array_key_exists($dokument->kategorie_kurzbz, $erlaubte))
$erlaubte[$dokument->kategorie_kurzbz] = $this->_dmsKategorieErlaubt($dokument->kategorie_kurzbz);
if ($erlaubte[$dokument->kategorie_kurzbz] === true)
$nachId[$dokument->dms_id] = $dokument;
}
// The editor chose the order. Keep it.
$sortiert = array();
foreach ($ids as $id)
{
if (isset($nachId[$id]))
$sortiert[] = $nachId[$id];
}
$this->terminateWithSuccess($sortiert);
}
/**
* Lists the child pages of a content, as a reader may see them.
* Used by the CMS content component "contentchild-menu".
*/
public function getContentChilds()
{
// form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural_no_zero');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('content/Contentchild_model', 'ContentchildModel');
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
if (!$sprache)
$sprache = getUserLanguage();
$childs = $this->ContentchildModel->getChildsForReader($content_id, $sprache, getAuthUID());
$this->terminateWithSuccess($this->getDataOrTerminateWithError($childs) ?: array());
}
//------------------------------------------------------------------------------------------------------------------
// Private methods
/**
* Builds the shape both person content components render.
*
* The assembly follows Cis/Cms/News/Xml/Address/Detailed.php, so a person reads the
* same in the news extras and on a content page.
*
* @param array $uids in the order the result should keep
* @param array $funktionen uid => function label, may be empty
* @param boolean $mitFoto
* @return array
*/
private function _cmsPersonen($uids, $funktionen, $mitFoto)
{
if (empty($uids))
return array();
$this->load->model('person/Person_model', 'PersonModel');
$kontakt = $this->PersonModel->getCmsKontakt($uids, $mitFoto);
$kontakt = $this->getDataOrTerminateWithError($kontakt) ?: array();
$nachUid = array();
foreach ($kontakt as $row)
$nachUid[$row->uid] = $row;
$personen = array();
// Loops over $uids, not over the result, so the caller keeps its order.
foreach ($uids as $uid)
{
if (!isset($nachUid[$uid]))
continue;
$row = $nachUid[$uid];
$name = trim(implode(' ', array_filter(array(
$row->titelpre, $row->vorname, $row->nachname, $row->titelpost
))));
$telefon = '';
if ($row->telefonklappe)
$telefon = trim(($row->kontakt ?: '') . ' - ' . $row->telefonklappe);
$email = '';
if (defined('DOMAIN'))
$email = ($row->alias ?: $row->uid) . '@' . DOMAIN;
$personen[] = array(
'uid' => $row->uid,
'name' => $name,
'funktion' => isset($funktionen[$uid]) ? $funktionen[$uid] : '',
'telefon' => $telefon,
'email' => $email,
'ort' => $row->planbezeichnung,
'foto' => $row->foto
);
}
return $personen;
}
/**
* Decides whether the current user may see a DMS category.
*
* Same rule as cms/dms.php: an unlocked category is open to everyone. A locked one
* needs a granted group, or the category permission together with basis/dms.
*
* @param string $kategorie_kurzbz
* @return boolean|null null when the category does not exist
*/
private function _dmsKategorieErlaubt($kategorie_kurzbz)
{
$this->load->library('PermissionLib');
$this->load->model('content/Dms_model', 'DmsModel');
$zugriff = $this->DmsModel->getKategorieZugriff($kategorie_kurzbz, getAuthUID());
$zugriff = $this->getDataOrTerminateWithError($zugriff);
if (empty($zugriff))
return null;
$kategorie = current($zugriff);
if (!$kategorie->gesperrt || $kategorie->in_gruppe)
return true;
$erlaubt = $this->permissionlib->isBerechtigt('basis/dms');
if ($erlaubt && $kategorie->berechtigung_kurzbz != '')
$erlaubt = $this->permissionlib->isBerechtigt($kategorie->berechtigung_kurzbz);
return $erlaubt;
}
}
@@ -16,6 +16,9 @@ class CmsAdmin extends FHCAPI_Controller
'getTemplates' => ['basis/cms:r'],
'getOrganisationseinheiten' => ['basis/cms:r'],
'getSprachen' => ['basis/cms:r'],
'getDmsKategorien' => ['basis/cms:r'],
'getMitarbeiter' => ['basis/cms:r'],
'getDmsKategorieDokumente' => ['basis/cms:r'],
'getUsage' => ['basis/cms:r'],
'getClickCounts' => ['basis/cms:r'],
'postContent' => ['basis/cms:rw'],
@@ -554,4 +557,46 @@ class CmsAdmin extends FHCAPI_Controller
return null;
}
/**
* Lists the DMS categories for the contentcomponent insert dialog.
*/
public function getDmsKategorien()
{
$this->load->model('content/Dms_model', 'DmsModel');
$result = $this->DmsModel->getKategorien();
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
/**
* Lists active staff for the contentcomponent insert dialog.
* Names only. See Mitarbeiter_model::getAktivesPersonalNamen().
*/
public function getMitarbeiter()
{
$this->load->model('ressource/Mitarbeiter_model', 'MitarbeiterModel');
$result = $this->MitarbeiterModel->getAktivesPersonalNamen();
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
/**
* Lists the documents of one DMS category for the contentcomponent insert dialog.
*/
public function getDmsKategorieDokumente()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('kategorie_kurzbz', 'DMS-Kategorie', 'required');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('content/Dms_model', 'DmsModel');
$result = $this->DmsModel->getKategorieDokumenteAlle($this->input->get('kategorie_kurzbz', TRUE));
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result) ?: array());
}
}
@@ -33,6 +33,68 @@ class Contentchild_model extends DB_Model
return $this->execReadOnlyQuery($query, [$sprache, $content_id]);
}
/**
* Children of a content as a reader may see them.
*
* Differs from getChilds() on purpose. getChilds() serves the admin and returns every
* child with the lowest version. A reader needs the newest visible version, only active
* contents, and no page that a group rule keeps from them.
*
* The group rule follows CmsLib::getContent(): a content without a row in
* tbl_contentgruppe is open, a content with rows needs membership in one of them.
*
* @param int $content_id
* @param string $sprache
* @param string $uid
* @return stdClass success with array of rows or error
*/
public function getChildsForReader($content_id, $sprache, $uid)
{
$query = '
SELECT
cc.child_content_id AS content_id,
cc.sort,
COALESCE(
(SELECT cs.titel FROM campus.tbl_contentsprache cs
WHERE cs.content_id = cc.child_content_id
AND cs.sprache = ?
AND cs.sichtbar
AND LENGTH(cs.titel) > 0
ORDER BY cs.version DESC NULLS LAST LIMIT 1),
(SELECT cs.titel FROM campus.tbl_contentsprache cs
WHERE cs.content_id = cc.child_content_id
AND cs.sichtbar
AND LENGTH(cs.titel) > 0
ORDER BY cs.version DESC NULLS LAST LIMIT 1),
c.beschreibung
) AS titel
FROM campus.tbl_contentchild cc
JOIN campus.tbl_content c ON c.content_id = cc.child_content_id
WHERE cc.content_id = ?
AND c.aktiv
-- Without a visible version the reader cannot open the page at all.
AND EXISTS (
SELECT 1 FROM campus.tbl_contentsprache cs
WHERE cs.content_id = cc.child_content_id AND cs.sichtbar
)
AND (
NOT EXISTS (
SELECT 1 FROM campus.tbl_contentgruppe cg
WHERE cg.content_id = cc.child_content_id
)
OR EXISTS (
SELECT 1 FROM campus.tbl_contentgruppe cg
JOIN public.vw_gruppen vg ON vg.gruppe_kurzbz = cg.gruppe_kurzbz
WHERE cg.content_id = cc.child_content_id AND vg.uid = ?
)
)
ORDER BY cc.sort, cc.contentchild_id
';
return $this->execReadOnlyQuery($query, [$sprache, $content_id, $uid]);
}
/**
* Highest sort value. Returns 0 if no row exists.
* @param int $content_id
+202
View File
@@ -30,4 +30,206 @@ class Dms_model extends DB_Model
return $returnArray;
}
/**
* Reads a DMS category and the access facts a listing needs.
*
* Mirrors the rule of cms/dms.php and dms.class.php at category level:
* a category is locked when any category in its parent chain grants a group or
* carries a berechtigung_kurzbz. The caller decides, this method only reports.
*
* @param string $kategorie_kurzbz
* gesperrt and in_gruppe come back as 0 or 1, not as a boolean. A PostgreSQL boolean
* reaches PHP as the string 'f' when the driver conversion does not run, and 'f' is
* truthy. The int cast is correct in both cases.
*
* @param string $uid
* @return object one row, or an empty result when the category does not exist
*/
public function getKategorieZugriff($kategorie_kurzbz, $uid)
{
// tiefe guards against a cyclic parent chain.
$query = '
WITH RECURSIVE chain(kategorie_kurzbz, parent_kategorie_kurzbz, berechtigung_kurzbz, tiefe) AS (
SELECT kategorie_kurzbz, parent_kategorie_kurzbz, berechtigung_kurzbz, 1
FROM campus.tbl_dms_kategorie
WHERE kategorie_kurzbz = ?
UNION ALL
SELECT k.kategorie_kurzbz, k.parent_kategorie_kurzbz, k.berechtigung_kurzbz, c.tiefe + 1
FROM campus.tbl_dms_kategorie k
JOIN chain c ON k.kategorie_kurzbz = c.parent_kategorie_kurzbz
WHERE c.tiefe < 32
)
SELECT
kat.kategorie_kurzbz,
kat.bezeichnung,
kat.beschreibung,
kat.berechtigung_kurzbz,
(
EXISTS (SELECT 1 FROM chain WHERE chain.berechtigung_kurzbz IS NOT NULL)
OR EXISTS (
SELECT 1
FROM campus.tbl_dms_kategorie_gruppe g
JOIN chain ON chain.kategorie_kurzbz = g.kategorie_kurzbz
)
)::int AS gesperrt,
(EXISTS (
SELECT 1
FROM campus.tbl_dms_kategorie_gruppe g
JOIN chain ON chain.kategorie_kurzbz = g.kategorie_kurzbz
JOIN public.tbl_benutzergruppe b ON b.gruppe_kurzbz = g.gruppe_kurzbz
WHERE b.uid = ?
))::int AS in_gruppe
FROM campus.tbl_dms_kategorie kat
WHERE kat.kategorie_kurzbz = ?
';
return $this->execReadOnlyQuery($query, array($kategorie_kurzbz, $uid, $kategorie_kurzbz));
}
/**
* Lists the newest version of every document in one DMS category.
*
* filename stays out of the result. The reader gets the document through
* cms/dms.php?id=<dms_id>, never through the name on disk.
*
* @param string $kategorie_kurzbz
* @return object
*/
public function getKategorieDokumente($kategorie_kurzbz)
{
$query = '
SELECT
d.dms_id,
v.version,
v.name,
v.beschreibung,
v.mimetype,
COALESCE(v.updateamum, v.insertamum) AS geaendert
FROM campus.tbl_dms d
JOIN campus.tbl_dms_version v ON v.dms_id = d.dms_id
WHERE d.kategorie_kurzbz = ?
-- cis_suche marks a document as published to CIS readers. A category also
-- holds working files, and those must not appear on a content page.
AND v.cis_suche
AND v.version = (
SELECT MAX(version) FROM campus.tbl_dms_version WHERE dms_id = d.dms_id
)
-- A project document carries its own rule in dms.class.php and does not
-- belong in a CMS category listing.
AND NOT EXISTS (
SELECT 1 FROM fue.tbl_projekt_dokument pd WHERE pd.dms_id = d.dms_id
)
ORDER BY LOWER(v.name)
';
return $this->execReadOnlyQuery($query, array($kategorie_kurzbz));
}
/**
* Newest version of the named documents, in no particular order.
*
* Unlike getKategorieDokumente() this does NOT filter on cis_suche. An editor who names
* a dms_id has chosen that document on purpose, and the flag only marks a document for
* the CIS search. The category rule still applies, and the caller enforces it.
*
* @param array $ids dms_id values
* @return object
*/
public function getDokumenteByIds($ids)
{
if (empty($ids))
return success(array());
$platzhalter = implode(', ', array_fill(0, count($ids), '?'));
$query = '
SELECT
d.dms_id,
d.kategorie_kurzbz,
v.version,
v.name,
v.beschreibung,
v.mimetype,
COALESCE(v.updateamum, v.insertamum) AS geaendert
FROM campus.tbl_dms d
JOIN campus.tbl_dms_version v ON v.dms_id = d.dms_id
WHERE d.dms_id IN (' . $platzhalter . ')
AND v.version = (
SELECT MAX(version) FROM campus.tbl_dms_version WHERE dms_id = d.dms_id
)
AND NOT EXISTS (
SELECT 1 FROM fue.tbl_projekt_dokument pd WHERE pd.dms_id = d.dms_id
)
';
return $this->execReadOnlyQuery($query, array_values($ids));
}
/**
* Every document of one category, for the editor picker.
*
* Unlike getKategorieDokumente() this does NOT filter on cis_suche, because
* dms-dokumente renders named documents regardless of that flag. The flag travels with
* the row so the picker can mark a document that no reader would find by search.
*
* @param string $kategorie_kurzbz
* @return object
*/
public function getKategorieDokumenteAlle($kategorie_kurzbz)
{
$query = '
SELECT
d.dms_id,
v.name,
v.cis_suche::int AS cis_suche
FROM campus.tbl_dms d
JOIN LATERAL (
SELECT v.name, v.cis_suche
FROM campus.tbl_dms_version v
WHERE v.dms_id = d.dms_id
ORDER BY v.version DESC
LIMIT 1
) v ON TRUE
WHERE d.kategorie_kurzbz = ?
AND NOT EXISTS (
SELECT 1 FROM fue.tbl_projekt_dokument pd WHERE pd.dms_id = d.dms_id
)
ORDER BY LOWER(v.name)
';
return $this->execReadOnlyQuery($query, array($kategorie_kurzbz));
}
/**
* Lists every DMS category with the number of documents it holds.
* Used by the insert dialog of the CMS content component "dms-liste".
*
* @return object
*/
public function getKategorien()
{
$query = '
SELECT
k.kategorie_kurzbz,
k.bezeichnung,
k.oe_kurzbz,
COALESCE(a.anzahl, 0) AS anzahl
FROM campus.tbl_dms_kategorie k
LEFT JOIN (
SELECT d.kategorie_kurzbz, COUNT(*) AS anzahl
FROM campus.tbl_dms d
JOIN (
SELECT DISTINCT ON (dms_id) dms_id, cis_suche
FROM campus.tbl_dms_version
ORDER BY dms_id, version DESC
) v ON v.dms_id = d.dms_id
WHERE v.cis_suche
GROUP BY d.kategorie_kurzbz
) a ON a.kategorie_kurzbz = k.kategorie_kurzbz
ORDER BY LOWER(COALESCE(k.bezeichnung, k.kategorie_kurzbz))
';
return $this->execReadOnlyQuery($query);
}
}
@@ -243,6 +243,58 @@ class Person_model extends DB_Model
* @param $uid
* @return array
*/
/**
* Contact data of the named users, for the CMS content components.
*
* The joins follow Benutzerfunktion_model::getBenutzerFunktionenDetailed() and the view
* Cis/Cms/News/Xml/Address/Detailed.php, so a person reads the same everywhere.
*
* foto_sperre is honoured. A person who blocked their photo never gets one on a public
* content page, no matter what the page asks for.
*
* @param array $uids
* @param boolean $mitFoto photos are large, so they are read only on request
* @return object
*/
public function getCmsKontakt($uids, $mitFoto = false)
{
if (empty($uids))
return success(array());
$platzhalter = implode(', ', array_fill(0, count($uids), '?'));
$fotoSpalte = $mitFoto
? 'CASE WHEN p.foto_sperre THEN NULL ELSE p.foto END'
: 'NULL';
$query = '
SELECT
b.uid,
b.alias,
p.vorname,
p.nachname,
p.titelpre,
p.titelpost,
m.telefonklappe,
k.kontakt,
o.planbezeichnung,
' . $fotoSpalte . ' AS foto
FROM public.tbl_benutzer b
JOIN public.tbl_person p ON p.person_id = b.person_id
LEFT JOIN public.tbl_mitarbeiter m ON m.mitarbeiter_uid = b.uid
LEFT JOIN public.tbl_kontakt k
ON k.standort_id = m.standort_id AND k.kontakttyp = ?
LEFT JOIN public.tbl_ort o ON o.ort_kurzbz = m.ort_kurzbz
WHERE b.uid IN (' . $platzhalter . ')
AND b.aktiv
';
$parameter = array('telefon');
foreach ($uids as $uid)
$parameter[] = $uid;
return $this->execReadOnlyQuery($query, $parameter);
}
public function getByUid($uid)
{
$this->addSelect('vorname, nachname, gebdatum, person_id, bpk, matr_nr, foto');
@@ -98,6 +98,31 @@ class Mitarbeiter_model extends DB_Model
return $this->execQuery($qry, $params);
}
/**
* Active staff, names only, for a picker.
*
* getPersonal() returns tbl_person.* including svnr, bpk and
* gebdatum, which is not needed in a CMS endpoint.
*
* @return object
*/
public function getAktivesPersonalNamen()
{
$query = '
SELECT
b.uid,
p.vorname,
p.nachname
FROM public.tbl_benutzer b
JOIN public.tbl_mitarbeiter m ON m.mitarbeiter_uid = b.uid
JOIN public.tbl_person p ON p.person_id = b.person_id
WHERE b.aktiv
ORDER BY LOWER(p.nachname), LOWER(p.vorname)
';
return $this->execReadOnlyQuery($query);
}
/**
* gibt Personen mit Übersicht von Vertragsdaten aus
*
+10 -56
View File
@@ -84,65 +84,18 @@ if (! $rechte->isberechtigt('basis/dms', null, 's', null))
}
/*
* ---------------------------------------------------------------------------------
* CIS4 BRIDGE -- ticket 78489, CMS admin on CI3 and Vue
* ---------------------------------------------------------------------------------
* CIS4 bridge, ticket 78489.
*
* WHY THIS CHANGE IS HERE AND NOT SOMEWHERE ELSE
* The TinyMCE 5 editor of the new CMS admin cannot receive a selection through the
* TinyMCE 3 API below, so this window posts the URL back to its opener instead.
*
* The new CMS admin (application/controllers/Cms.php, public/js/components/Cms/) uses
* TinyMCE 5. This file returns its selection through the TinyMCE 3 API, that is
* through the global object tinyMCEPopup:
* message: { typ: 'fhc-dms-selection', url: 'dms.php?id=<dms_id>' }
* target: window.opener, origin checked on both sides
*
* tinyMCEPopup.getWindowArg("window") -> the calling window
* tinyMCEPopup.getWindowArg("input") -> the target field id in that window
* tinyMCEPopup.close() -> close this window
*
* tinyMCEPopup does not exist in TinyMCE 5. It came with the plugin "inlinepopups",
* which TinyMCE 4 removed. TinyMCE 5 uses file_picker_callback instead and expects
* the caller to hand back the URL.
*
* The return code must run IN THIS window, because only this window knows which
* dms_id the editor clicked. A wrapper in the new admin cannot catch that: it sees
* the opened window, not the click inside it. A proxy controller does not help for
* the same reason. The selection happens here.
*
* WHAT WAS DELIBERATELY NOT CHANGED
*
* The ticket excludes the DMS input interface, because branches for it are open.
* This change respects that:
*
* - It only adds. The legacy branch below stays line for line and still runs
* unchanged as soon as tinyMCEPopup is present.
* - It touches exactly one JavaScript function. No PHP, no markup, no query,
* no view, no layout.
* - It changes none of the four callers of mySubmit.
*
* When these branches are merged, only this one function needs attention.
*
* WHY NO GET PARAMETER TELLS THE TWO PATHS APART
*
* A switch such as ?cis4=1 on open looks obvious, but it does not hold: the file
* browser navigates internally (search, paging, category change) and rebuilds its
* links from $_REQUEST. An extra parameter is lost on the way, and the bridge would
* be dead after the first search. The runtime check through tinyMCEPopup and
* window.opener survives every internal navigation and needs no change to the links.
*
* THE CONTRACT WITH THE NEW ADMIN
*
* message: { typ: 'fhc-dms-selection', url: 'dms.php?id=<dms_id>' }
* target: window.opener
* origin: window.location.origin (the receiver checks against it)
*
* The URL stays RELATIVE. This is mandatory, not carelessness:
* - The legacy view runs under /cms/ and resolves dms.php?id=N directly.
* - The CIS4 view replaces the string 'dms.php' in CmsLib::getContent() with
* APP_ROOT . 'cms/dms.php'.
* An absolute URL breaks both paths. Stored content must stay interchangeable
* between the two interfaces.
* The path is chosen by window.name, which the opener sets and which survives the
* internal navigation of this browser.
*
* Receiving side: public/js/components/Cms/Form/FieldWysiwyg.js
* ---------------------------------------------------------------------------------
*/
var FileBrowserDialog=
{
@@ -151,8 +104,9 @@ if (! $rechte->isberechtigt('basis/dms', null, 's', null))
mySubmit : function (id) {
var URL = "dms.php?id="+id;
// CIS4 path: see the comment block above this function.
if (typeof tinyMCEPopup === 'undefined' && window.opener && !window.opener.closed) {
// CIS4 path: see the comment block above this function. Do not test for
// tinyMCEPopup, this page loads tiny_mce_popup.js itself and always defines it.
if (window.name === 'DMS_CIS4' && window.opener && !window.opener.closed) {
window.opener.postMessage({ typ: 'fhc-dms-selection', url: URL }, window.location.origin);
window.close();
return;
+40
View File
@@ -54,6 +54,46 @@ export default {
url: '/api/frontend/v1/Cms/getNewsRowCount'
};
},
// Used by the CMS content component "oe-personen".
getOePersonen(oe_kurzbz, foto) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/getOePersonen',
params: { oe_kurzbz, ...(foto ? { foto: 1 } : {}) }
};
},
// Used by the CMS content component "person-block".
getPerson(uid, foto) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/getPerson',
params: { uid, ...(foto ? { foto: 1 } : {}) }
};
},
// Used by the CMS content component "dms-dokumente".
getDmsDokumente(dms_ids) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/getDmsDokumente',
params: { dms_ids }
};
},
// Used by the CMS content component "dms-liste".
getDmsKategorie(kategorie_kurzbz) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/getDmsKategorie',
params: { kategorie_kurzbz }
};
},
// Used by the CMS content component "contentchild-menu".
getContentChilds(content_id, sprache) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/getContentChilds',
params: { content_id, ...(sprache ? { sprache } : {}) }
};
},
getNewsExtra() {
// TODO(chris): seems to be called from nowhere?
return {
+22
View File
@@ -55,6 +55,28 @@ export default {
url: '/api/frontend/v1/CmsAdmin/getSprachen'
};
},
// Fills the DMS category field of the contentcomponent insert dialog.
getDmsKategorien() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getDmsKategorien'
};
},
// Fills the person field of the contentcomponent insert dialog.
getMitarbeiter() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getMitarbeiter'
};
},
// Fills the document field of the contentcomponent insert dialog.
getDmsKategorieDokumente(kategorie_kurzbz) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getDmsKategorieDokumente',
params: { kategorie_kurzbz }
};
},
getUsage(content_id) {
return {
method: 'get',
@@ -1,4 +1,5 @@
import { replaceRelativeLegacyLink } from "../../../../helpers/LegacyLinkReplaceHelper.js"
import ContentcomponentHost from "../../../Contentcomponents/ContentcomponentHost.js";
export default {
name: "GeneralComponent",
props:{
@@ -6,7 +7,13 @@ export default {
type:String,
required:true,
},
content_id:{
type: [Number, String],
},
},
components: {
ContentcomponentHost
},
methods: {
sanitizeLegacyTables(table) {
@@ -124,7 +131,7 @@ export default {
template: /*html*/ `
<!-- div that contains the content -->
<div v-if="content" class="container" style="max-width: 100%;"><div class="row"><div class="col">
<div v-html="content" ></div>
<contentcomponent-host :content="content" :content-id="content_id"></contentcomponent-host>
</div></div></div>
<p v-else>Content was not found</p>
`,
@@ -134,6 +134,7 @@ export default {
template: /*html*/ `
<!-- div that contains the content -->
<div v-if="content" class="container" style="max-width: 100%;"><div class="row"><div class="col">
<!-- No contentcomponent-host on purpose: news contents stay static by convention. -->
<div v-html="content" ></div>
</div></div></div>
<p v-else>Content was not found</p>
@@ -1,4 +1,4 @@
import ContentcomponentHost from "../../../Contentcomponents/ContentcomponentHost.js";
export default {
name: "RaumComponent",
data() {
@@ -15,6 +15,9 @@ export default {
type: [Number, String],
}
},
components: {
ContentcomponentHost
},
methods: {
sanitizeLegacyTables(table) {
@@ -135,7 +138,7 @@ export default {
<!-- div that contains the content -->
<!-- TODO: test with more img content from cms-->
<div v-if="imgContent"><img v-bind="imgContent"/></div>
<div v-html="content" v-else-if="content" ></div>
<contentcomponent-host v-else-if="content" :content="content" :content-id="content_id"></contentcomponent-host>
<p v-else>Content was not found</p>
`,
};
+1
View File
@@ -117,6 +117,7 @@ export default {
></pagination>
<div class="container-fluid mt-4">
<div class="row">
<!-- No contentcomponent-host on purpose: news stays static by convention. -->
<div class="col" v-html="content">
</div>
<div class="col-auto">
+71 -21
View File
@@ -1,3 +1,6 @@
import { CONTENTCOMPONENT_CLASS, CONTENTCOMPONENT_ATTR } from '../../Contentcomponents/catalog.js';
import { createContentcomponentDialog } from './ContentcomponentDialog.js';
export default {
name: 'FieldWysiwyg',
props: {
@@ -16,6 +19,37 @@ export default {
methods: {
getContent() {
return this.editor ? this.editor.getContent() : this.modelValue;
},
// Opens the legacy DMS browser and resolves with the relative URL it posts back.
// Used for images and links, where the editor needs the file preview of that
// browser. The contentcomponent dialog picks documents from a list instead, without
// leaving the dialog. The bridge in cms/tinymce_dms.php sends { typ, url } to
// window.opener and closes itself.
openDmsBrowser() {
const vm = this;
return new Promise(resolve => {
if (vm._dmsListener)
window.removeEventListener('message', vm._dmsListener);
const url = FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cms/tinymce_dms.php';
// The window name selects the bridge path in cms/tinymce_dms.php and survives
// its internal navigation. The legacy admin opens the same page as 'DMS'.
window.open(url, 'DMS_CIS4', 'width=1400,height=850,scrollbars=1,resizable=1');
const onMessage = (event) => {
if (event.origin !== window.location.origin) return;
if (!event.data || event.data.typ !== 'fhc-dms-selection') return;
window.removeEventListener('message', onMessage);
vm._dmsListener = null;
resolve(event.data.url);
};
vm._dmsListener = onMessage;
window.addEventListener('message', onMessage);
});
}
},
watch: {
@@ -27,6 +61,13 @@ export default {
},
mounted() {
const vm = this;
// The dialogs live in their own module. This file stays with the editor itself.
this.contentcomponentDialog = createContentcomponentDialog({
api: this.$api,
onChanged: () => vm.$emit('update:modelValue', vm.editor.getContent())
});
tinymce.init({
target: this.$refs.editorArea,
height: 500,
@@ -34,6 +75,15 @@ export default {
// valid_elements: '*[*]' permits every tag and attribute. Existing content
// contains audio, video and source tags. A stricter setting deletes them silently.
valid_elements: '*[*]',
// A contentcomponent marker is one atomic block. The editor must not type into it.
noneditable_noneditable_class: CONTENTCOMPONENT_CLASS,
// The marker renders nothing here, so the editor has to show that it is there.
content_style: '.' + CONTENTCOMPONENT_CLASS + '{'
+ 'display:block;margin:.5rem 0;padding:.5rem;min-height:2.5rem;'
+ 'border:1px dashed #7a7a7a;background:#f2f2f2;cursor:pointer;}'
+ '.' + CONTENTCOMPONENT_CLASS + '::before{'
+ 'content:"Contentcomponent: " attr(' + CONTENTCOMPONENT_ATTR + ');'
+ 'font:12px monospace;color:#555;}',
forced_root_block: '',
style_formats: [
{ title: 'Contenttabelle', selector: 'table', classes: 'cmstable' },
@@ -44,35 +94,35 @@ export default {
{ title: 'Tablesorter', selector: 'table',
classes: 'tablesorter tablesorter_col_0 tablesorter_sort_0' }
],
plugins: 'code lists link image table fullscreen paste searchreplace charmap directionality visualblocks anchor hr',
plugins: 'code lists link image table fullscreen paste searchreplace charmap directionality visualblocks anchor hr noneditable',
toolbar: [
'code | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | styleselect fontsizeselect',
'code | contentcomponent | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | styleselect fontsizeselect',
'pastetext | searchreplace | bullist numlist | outdent indent blockquote | undo redo | link unlink anchor image | forecolor backcolor',
'table | hr removeformat visualblocks | subscript superscript | charmap | ltr rtl | fullscreen'
],
// The relative form dms.php?id=N is mandatory. The legacy view runs under /cms/
// and resolves it directly. The CIS4 view replaces it in CmsLib::getContent
// with APP_ROOT . 'cms/dms.php'. An absolute URL breaks both paths.
file_picker_callback: (callback, value, meta) => {
if (vm._dmsListener) {
window.removeEventListener('message', vm._dmsListener);
}
const url = FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cms/tinymce_dms.php';
window.open(url, 'DMS', 'width=1400,height=850,scrollbars=1,resizable=1');
const onMessage = (event) => {
if (event.origin !== window.location.origin) return;
if (!event.data || event.data.typ !== 'fhc-dms-selection') return;
// The relative form dms.php?id=N is mandatory. The legacy view runs under /cms/
// and resolves it directly. The CIS4 view replaces it in CmsLib::getContent
// with APP_ROOT . 'cms/dms.php'. An absolute URL breaks both paths.
callback(event.data.url);
window.removeEventListener('message', onMessage);
vm._dmsListener = null;
};
vm._dmsListener = onMessage;
window.addEventListener('message', onMessage);
vm.openDmsBrowser().then(callback);
},
setup: (editor) => {
vm.editor = editor;
editor.ui.registry.addButton('contentcomponent', {
text: 'Contentcomponent',
tooltip: 'Contentcomponent einfügen',
onAction: () => vm.contentcomponentDialog.openPicker(editor)
});
// A double click on a marker reopens its property dialog.
editor.on('dblclick', (event) => {
const node = editor.dom.getParent(event.target, '.' + CONTENTCOMPONENT_CLASS);
if (node)
vm.contentcomponentDialog.openMarker(editor, node);
});
editor.on('init', () => {
editor.setContent(vm.modelValue || '');
if (vm.disabled) editor.mode.set('readonly');
+14
View File
@@ -1,6 +1,7 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
import XsdForm from '../Form/XsdForm.js';
import CmsPreview from '../Preview.js';
import { CONTENTCOMPONENT_ATTR } from '../../Contentcomponents/catalog.js';
export default {
name: 'CmsContent',
@@ -26,6 +27,13 @@ export default {
};
},
computed: {
// The preview below uses the legacy renderer, which runs no Vue. Tell the editor
// as soon as the content holds a marker, so an empty preview is not a surprise.
hasContentcomponents() {
return Object.values(this.values || {}).some(
value => typeof value === 'string' && value.indexOf(CONTENTCOMPONENT_ATTR) !== -1
);
},
lockState() {
if (!this.sperre || this.sperre.gesperrt_uid === null) return 'free';
if (this.sperre.own) return 'own';
@@ -162,6 +170,12 @@ export default {
</button>
</template>
<div v-if="hasContentcomponents" class="alert alert-info">
Dieser Inhalt enthält Contentcomponents. Die Vorschau unten zeigt sie nicht,
weil sie den alten Renderer ohne Vue verwendet. Prüfe sie über den Link
"In CIS4 ansehen".
</div>
<cms-preview
ref="preview"
:content-id="contentId"
@@ -0,0 +1,84 @@
import ApiCms from '../../api/factory/cms.js';
/**
* Content component: lists the child pages of a content as a menu.
*
* It replaces a hand written box of content.php links. Those links carry a fixed
* content_id and break when a page moves. This list follows tbl_contentchild, which the
* Kindelemente tab of the CMS admin already maintains.
*
* Without the content-id property the menu shows the children of the page it sits on.
* With it, the menu shows the children of another page. The second form exists because
* the same menu appears on several pages today.
*/
export default {
name: 'ContentchildMenu',
inject: {
contentcomponentContentId: { default: null }
},
props: {
contentId: {
type: [Number, String],
default: null
}
},
data() {
return {
kinder: [],
loading: true,
failed: false
};
},
computed: {
sprache() {
return this.$p.user_language.value;
},
// The property wins. Without it the menu takes the page it sits on.
// Vue unwraps an injected ref on the instance, so no .value here.
quelle() {
return this.contentId || this.contentcomponentContentId;
}
},
methods: {
load() {
if (!this.quelle)
{
this.loading = false;
return;
}
this.loading = true;
this.failed = false;
this.$api
.call(ApiCms.getContentChilds(this.quelle, this.sprache))
.then(res => { this.kinder = res.data || []; })
.catch(() => { this.failed = true; })
.finally(() => { this.loading = false; });
}
},
watch: {
quelle() { this.load(); },
// The titles come per language, so a language switch needs a new request.
sprache() { this.load(); }
},
created() {
this.load();
},
template: /*html*/ `
<div class="fhc-contentcomponent-contentchildmenu">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
Das Menü konnte nicht geladen werden.
</div>
<!-- An empty menu renders nothing. A reader has no use for the note that a
page has no children, and the group rule may have removed every entry. -->
<ul v-else-if="kinder.length" class="list-unstyled mb-0">
<li v-for="kind in kinder" :key="kind.content_id" class="mb-1">
<router-link :to="{ name: 'Content', params: { content_id: kind.content_id } }">
{{ kind.titel }}
</router-link>
</li>
</ul>
</div>
`
};
@@ -0,0 +1,74 @@
import { components } from './components.js';
import { scanContentcomponents } from './scan.js';
/**
* Renders CMS content HTML and mounts the content components it declares.
*
* Use it in place of a plain `<div v-html="content">`. It owns the root element, the
* change watch and the Teleports, so a host site needs no ref and no lifecycle code.
*
* Teleport, not a separate createApp: the components stay in this app's component tree.
* They therefore share the one $api instance, the one $p instance, the router and the
* provided keys. A createApp per marker would build a second of each.
*/
export default {
name: 'ContentcomponentHost',
props: {
content: {
type: String,
default: ''
},
// The page this content belongs to. contentchild-menu needs it when the editor
// gave no explicit id.
contentId: {
type: [Number, String],
default: null
}
},
provide() {
// Vue.computed keeps the value live, the same way Cis.js provides its keys.
return {
contentcomponentContentId: Vue.computed(() => this.contentId)
};
},
data() {
return {
markers: []
};
},
methods: {
rescan() {
this.markers = scanContentcomponents(this.$refs.root);
},
resolve(name) {
return components[name];
}
},
watch: {
// Vue replaces the whole v-html subtree when the string changes. A marker that
// still points at a removed node makes Teleport write into a detached element,
// and the component vanishes without an error. Drop the markers first, let the
// new HTML render, then scan again.
content() {
this.markers = [];
this.$nextTick(this.rescan);
}
},
// A failing content component must not take down the content around it.
errorCaptured(err, instance, info) {
console.error('Contentcomponent failed:', info, err);
return false;
},
mounted() {
this.rescan();
},
beforeUnmount() {
this.markers = [];
},
template: /*html*/ `
<div ref="root" v-html="content"></div>
<Teleport v-for="marker in markers" :key="marker.key" :to="marker.el">
<component :is="resolve(marker.name)" v-bind="marker.props"></component>
</Teleport>
`
};
@@ -0,0 +1,56 @@
import ApiCms from '../../api/factory/cms.js';
import DokumentListe from './DokumentListe.js';
/**
* Content component: named DMS documents, in the order the editor gave.
*/
export default {
name: 'DmsDokumente',
components: {
DokumentListe
},
props: {
dmsIds: {
type: String,
required: true
}
},
data() {
return {
dokumente: [],
loading: true,
failed: false
};
},
methods: {
load() {
this.loading = true;
this.failed = false;
this.$api
.call(ApiCms.getDmsDokumente(this.dmsIds))
.then(res => { this.dokumente = res.data || []; })
.catch(() => { this.failed = true; })
.finally(() => { this.loading = false; });
}
},
watch: {
dmsIds() { this.load(); }
},
created() {
this.load();
},
template: /*html*/ `
<div class="fhc-contentcomponent-dmsdokumente">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
Die Dokumentenliste konnte nicht geladen werden.
</div>
<!-- An empty list renders nothing. Every named document may have dropped out
through the category rule, and that is not the reader's business. -->
<dokument-liste
v-else-if="dokumente.length"
:dokumente="dokumente"
></dokument-liste>
</div>
`
};
@@ -0,0 +1,66 @@
import ApiCms from '../../api/factory/cms.js';
import DokumentListe from './DokumentListe.js';
/**
* Content component: the published documents of one DMS category.
*
* The low maintenance variant. It lists every document of the category whose newest
* version carries cis_suche, so a replaced or added document appears without an edit in
* the CMS. Use dms-dokumente when the editor must pick single files.
*/
export default {
name: 'DmsListe',
components: {
DokumentListe
},
props: {
kategorieKurzbz: {
type: String,
required: true
}
},
data() {
return {
dokumente: [],
zugriff: true,
loading: true,
failed: false
};
},
methods: {
load() {
this.loading = true;
this.failed = false;
this.$api
.call(ApiCms.getDmsKategorie(this.kategorieKurzbz))
.then(res => {
this.zugriff = res.data.zugriff;
this.dokumente = res.data.dokumente || [];
})
.catch(() => { this.failed = true; })
.finally(() => { this.loading = false; });
}
},
watch: {
kategorieKurzbz() { this.load(); }
},
created() {
this.load();
},
template: /*html*/ `
<div class="fhc-contentcomponent-dmsliste">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
Die Dokumentenliste konnte nicht geladen werden.
</div>
<!-- A reader without the entitlement sees nothing at all, not a hint that
something is hidden here. -->
<template v-else-if="zugriff">
<div v-if="!dokumente.length" class="text-muted">
Keine Dokumente vorhanden.
</div>
<dokument-liste v-else :dokumente="dokumente"></dokument-liste>
</template>
</div>
`
};
@@ -0,0 +1,59 @@
// Mimetype to Font Awesome 6 icon. The first match wins.
const ICONS = [
[/pdf/, 'fa-file-pdf'],
[/word|opendocument\.text/, 'fa-file-word'],
[/excel|sheet|csv/, 'fa-file-excel'],
[/powerpoint|presentation/, 'fa-file-powerpoint'],
[/^image\//, 'fa-file-image'],
[/zip|compressed|tar|rar|7z/, 'fa-file-zipper'],
[/^video\//, 'fa-file-video'],
[/^audio\//, 'fa-file-audio'],
[/^text\//, 'fa-file-lines']
];
/**
* Renders a list of DMS documents as download links.
*
* It fetches nothing. dms-liste and dms-dokumente both fill it, so a document reads the
* same whether the editor named a category or single ids.
*/
export default {
name: 'DokumentListe',
props: {
dokumente: {
type: Array,
default: () => []
}
},
methods: {
icon(mimetype) {
const typ = (mimetype || '').toLowerCase();
const treffer = ICONS.find(([muster]) => muster.test(typ));
return treffer ? treffer[1] : 'fa-file';
},
href(dokument) {
return FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cms/dms.php?id=' + dokument.dms_id;
},
datum(wert) {
if (!wert) return '';
const teile = String(wert).slice(0, 10).split('-');
return teile.length === 3 ? teile[2] + '.' + teile[1] + '.' + teile[0] : '';
}
},
template: /*html*/ `
<ul class="list-unstyled mb-0">
<li v-for="dokument in dokumente" :key="dokument.dms_id" class="mb-2">
<a :href="href(dokument)" target="_blank" rel="noopener">
<i class="fa-regular" :class="icon(dokument.mimetype)"></i>
{{ dokument.name }}
</a>
<span v-if="dokument.geaendert" class="text-muted small ms-2">
{{ datum(dokument.geaendert) }}
</span>
<div v-if="dokument.beschreibung" class="text-muted small">
{{ dokument.beschreibung }}
</div>
</li>
</ul>
`
};
@@ -0,0 +1,78 @@
import ApiCms from '../../api/factory/cms.js';
import PersonBlock from './PersonBlock.js';
/**
* Content component: every person assigned to an organisation unit.
*
* The low maintenance variant. A new colleague appears without an edit in the CMS, and a
* leaving one disappears. The price is that the editor cannot steer who is shown. Use
* person-block when that control matters.
*
* The function label per person comes from tbl_benutzerfunktion.bezeichnung,
* and falls back to tbl_funktion.beschreibung.
*/
export default {
name: 'OePersonen',
components: {
PersonBlock
},
props: {
oeKurzbz: {
type: String,
required: true
},
foto: {
type: Boolean,
default: false
}
},
data() {
return {
personen: [],
loading: true,
failed: false
};
},
methods: {
load() {
this.loading = true;
this.failed = false;
this.$api
.call(ApiCms.getOePersonen(this.oeKurzbz, this.foto))
.then(res => { this.personen = res.data || []; })
.catch(() => { this.failed = true; })
.finally(() => { this.loading = false; });
}
},
watch: {
oeKurzbz() { this.load(); },
foto() { this.load(); }
},
created() {
this.load();
},
template: /*html*/ `
<div class="fhc-contentcomponent-oepersonen">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
Die Personen konnten nicht geladen werden.
</div>
<div v-else-if="!personen.length" class="text-muted">
Keine Personen zugeordnet.
</div>
<template v-else>
<person-block
v-for="person in personen"
:key="person.uid"
:uid="person.uid"
:name="person.name"
:funktion="person.funktion"
:telefon="person.telefon"
:email="person.email"
:ort="person.ort"
:foto="person.foto"
></person-block>
</template>
</div>
`
};
@@ -0,0 +1,53 @@
/**
* Renders one person the way the teambox blocks do today: name, function, phone, room,
* mail, and an optional photo.
*
* It fetches nothing. person-block and oe-personen both fill it, so one person reads the
* same whether the editor named a UID or an organisation unit.
*/
export default {
name: 'PersonBlock',
props: {
uid: String,
name: String,
funktion: String,
telefon: String,
email: String,
ort: String,
foto: String
},
computed: {
// tel: takes digits and a leading plus.
telefonLink() {
return this.telefon ? 'tel:' + this.telefon.replace(/[^\d+]/g, '') : null;
},
fotoQuelle() {
return this.foto ? 'data:image/png;base64,' + this.foto : null;
}
},
template: /*html*/ `
<div class="fhc-contentcomponent-person mb-3">
<img
v-if="fotoQuelle"
:src="fotoQuelle"
alt=""
class="mb-2"
style="width: 110px; height: auto; object-fit: scale-down;"
>
<div>
<router-link v-if="uid" :to="{ name: 'ProfilView', params: { uid: uid } }">
{{ name }}
</router-link>
<span v-else>{{ name }}</span>
</div>
<div v-if="funktion">{{ funktion }}</div>
<div v-if="telefon">
T: <a :href="telefonLink">{{ telefon }}</a>
</div>
<div v-if="ort">R: {{ ort }}</div>
<div v-if="email">
E: <a :href="'mailto:' + email">{{ email }}</a>
</div>
</div>
`
};
@@ -0,0 +1,77 @@
import ApiCms from '../../api/factory/cms.js';
import PersonBlock from './PersonBlock.js';
/**
* Content component: one person, named by UID.
*
* The flexible variant. The editor decides who appears and in which order by placing
* several markers. Use oe-personen instead when the list should follow the organizational structure.
*
* The function label comes from the marker, not from the database, because the database
* name does not always fit. It needs no phrase key: the CMS holds one content per
* language, so the German page carries the German label and the English page the English
* one.
*/
export default {
name: 'PersonKontakt',
components: {
PersonBlock
},
props: {
uid: {
type: String,
required: true
},
funktion: {
type: String,
default: ''
},
foto: {
type: Boolean,
default: false
}
},
data() {
return {
person: null,
loading: true,
failed: false
};
},
methods: {
load() {
this.loading = true;
this.failed = false;
this.$api
.call(ApiCms.getPerson(this.uid, this.foto))
.then(res => { this.person = res.data; })
.catch(() => { this.failed = true; })
.finally(() => { this.loading = false; });
}
},
watch: {
uid() { this.load(); },
foto() { this.load(); }
},
created() {
this.load();
},
template: /*html*/ `
<div class="fhc-contentcomponent-personkontakt">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
Die Person konnte nicht geladen werden.
</div>
<person-block
v-else-if="person"
:uid="person.uid"
:name="person.name"
:funktion="funktion"
:telefon="person.telefon"
:email="person.email"
:ort="person.ort"
:foto="person.foto"
></person-block>
</div>
`
};
@@ -0,0 +1,113 @@
# Contentcomponents
Vue components that CMS editors can place inside CMS content. The editor inserts a marker
div, CIS4 finds it and teleports a real component into it. The component fetches its own
data and has everything a normal component has: `$api`, `$p`, the router, the provided
keys.
## The marker
```html
<div class="fhc-contentcomponent" data-fhc-component="dms-liste" data-kategorie-kurzbz="rl"></div>
```
Editors do not write this. The **Contentcomponent** button in TinyMCE 5 opens a dialog
built from `catalog.js`.
## Add a component
| File | What to add |
|---|---|
| `MeinBaustein.js` | the component |
| `catalog.js` | an entry in `catalog`: name, label, props |
| `components.js` | the import plus `'mein-baustein': MeinBaustein` |
Catalog and map are two hand-written lists. `components.js` logs a mismatch on load.
### The component
Copy `DmsListe.js`, the shortest complete example. Requirements:
- It stands alone. No parent props, no route params.
- It fetches in `created()`, and again from a `watch` on every prop.
- It handles loading, failure and empty.
- Root class `fhc-contentcomponent-<name>`.
- `template` string. There is no `.vue` build.
To get the id of the page the marker sits on:
```js
inject: { contentcomponentContentId: { default: null } }
```
`ContentchildMenu.js` shows the full pattern.
Presentational helpers belong in this folder but not in the catalog. See `PersonBlock.js`
and `DokumentListe.js`.
### The catalog entry
```js
{
name: 'mein-baustein', // data-fhc-component, and the key in components.js
label: 'Mein Baustein', // shown in the picker
props: {
'mein-wert': { type: 'string', required: true, label: 'Mein Wert' }
}
}
```
The catalog is an allow-list. `scan.js` reads only the attributes declared here and drops
every other one.
| Prop key | Meaning |
|---|---|
| `type` | `string`, `number` or `boolean`. Anything else stays a string. |
| `required` | A missing attribute makes `scan.js` skip the whole marker. The dialog does not enforce it. |
| `label` | Field label in the dialog. It defaults to the attribute name. |
| `source` | Fills the field from a server list: `oe`, `person`, `dmskategorie`. |
| `picker` | Only `'dms'`. Adds category and document selects that append ids to the field. |
## Gotchas
- **kebab-case attribute names.** The HTML parser lowercases them, so `data-meinWert` does
not survive. Vue maps `mein-wert` to the prop `meinWert`.
- **`boolean` means `"true"` or `"1"`.** Everything else is false, and the dialog never
writes a false into the marker.
- **Catalog name is not the file name.** `person-block` resolves to `PersonKontakt.js`.
- **The marker is a `div`, never a custom tag.** TinyMCE 3 in the legacy admin treats an
unknown tag as inline and can split it on save.
- **20 components per content** — `CONTENTCOMPONENT_MAX` in `catalog.js`.
- **Content is never compiled as a Vue template.** An editor cannot write expressions.
- **News renders no components on purpose.** A list shows many contents at once, and every
component would start its own request.
- A throwing component does not take the content down. The host catches it and logs it.
## Server data
Model, controller, factory. Follow `getDmsKategorie`.
1. `application/models/content/Dms_model.php` — the query.
2. `application/controllers/api/frontend/v1/Cms.php` — the method, plus
`'getMeineDaten' => self::PERM_LOGGED` in the constructor. Validate with
`form_validation`.
3. `public/js/api/factory/cms.js` — the request function.
A missing entitlement answers with success and an empty list, not with an error. The
reader gets no hint that something is hidden.
## A new dialog source
1. `CmsAdmin.php` — the endpoint, plus `['basis/cms:r']` in the constructor.
2. `cmsadmin.js` — the request function.
3. `ContentcomponentDialog.js` — an entry in `SOURCES`:
```js
meineliste: {
request: () => ApiCmsAdmin.getMeineListe(),
item: e => ({ value: e.kurzbz, text: e.bezeichnung })
}
```
4. `source: 'meineliste'` in the catalog prop.
@@ -0,0 +1,122 @@
/**
* Catalog of the components that may be placed inside CMS content.
*
* A component is available to CMS editors only if it has an entry here. An arbitrary
* project component cannot work in content: it expects props from a parent, inject keys
* and route params. Content supplies attribute strings only. A component listed here is
* written for content use: lightweight, one fetch, props chosen in the CMS.
*
* Marker convention in the content HTML:
*
* <div class="fhc-contentcomponent" data-fhc-component="NAME" data-PROP="VALUE"></div>
*
* A div is used instead of a custom tag on purpose. TinyMCE 5 (CIS4 admin) and TinyMCE 3
* (legacy admin) both treat a div as a block. An unknown tag counts as inline and the
* legacy admin can split or nest it on save, which would corrupt shared content.
*/
// Class on every marker. TinyMCE uses it for noneditable and for the editor styling.
export const CONTENTCOMPONENT_CLASS = 'fhc-contentcomponent';
// Attribute holding the component name.
export const CONTENTCOMPONENT_ATTR = 'data-fhc-component';
// Upper bound per content. A paste accident must not start dozens of requests.
export const CONTENTCOMPONENT_MAX = 20;
/**
* One entry per available component.
*
* name value of data-fhc-component, and the key in components.js
* label shown in the insert dialog. TODO(phrases): move to category cms.
* props attribute name (kebab-case) => { type, required, label, source }
*
* Attribute names must be kebab-case. The HTML parser lowercases every attribute name,
* so camelCase would not survive. Vue maps the kebab key back to the camelCase prop.
*
* TODO evaluate where "source" belongs. It tells the insert dialog how to fill the field.
* Only 'text' and 'oe' exist today. A growing list may deserve its own place.
*/
export const catalog = [
{
name: 'oe-personen',
label: 'Team einer Organisationseinheit (automatisch)',
props: {
'oe-kurzbz': {
type: 'string',
required: true,
label: 'Organisationseinheit',
source: 'oe'
},
'foto': {
type: 'boolean',
required: false,
label: 'Fotos anzeigen'
}
}
},
{
name: 'person-block',
label: 'Einzelne Person (Kontaktblock)',
props: {
'uid': {
type: 'string',
required: true,
label: 'Person',
source: 'person'
},
'funktion': {
type: 'string',
required: false,
label: 'Funktionsbezeichnung (leer = aus den Personaldaten)'
},
'foto': {
type: 'boolean',
required: false,
label: 'Foto anzeigen'
}
}
},
{
name: 'dms-dokumente',
label: 'Dokumentenliste (einzelne Dokumente)',
props: {
'dms-ids': {
type: 'string',
required: true,
label: 'DMS-IDs, mit Komma getrennt',
// Adds a category filter and a document list to the dialog. Choosing a
// document appends its id to this field.
picker: 'dms'
}
}
},
{
name: 'dms-liste',
label: 'Dokumentenliste (ganze DMS-Kategorie)',
props: {
'kategorie-kurzbz': {
type: 'string',
required: true,
label: 'DMS-Kategorie',
source: 'dmskategorie'
}
}
},
{
name: 'contentchild-menu',
label: 'Menü der Kindelemente',
props: {
'content-id': {
type: 'number',
required: false,
label: 'Content-ID (leer = diese Seite)'
}
}
}
];
export function getDescriptor(name)
{
return catalog.find(entry => entry.name === name) || null;
}
@@ -0,0 +1,33 @@
import { catalog } from './catalog.js';
import OePersonen from './OePersonen.js';
import DmsListe from './DmsListe.js';
import DmsDokumente from './DmsDokumente.js';
import PersonKontakt from './PersonKontakt.js';
import ContentchildMenu from './ContentchildMenu.js';
/**
* Component name => component.
*
* Static imports. At the current scale this is simpler than a dynamic import and it
* survives a rollup bundle. Switch to `(await import(file)).default`, the way
* Dashboard/Item.js loads a widget, once the catalog gets long.
*/
export const components = {
'oe-personen': OePersonen,
'person-block': PersonKontakt,
'dms-liste': DmsListe,
'dms-dokumente': DmsDokumente,
'contentchild-menu': ContentchildMenu
};
// The catalog and this map are written by hand and must name the same set.
for (const entry of catalog)
{
if (!components[entry.name])
console.error('Contentcomponent "' + entry.name + '" is in the catalog but has no component.');
}
for (const name of Object.keys(components))
{
if (!catalog.some(entry => entry.name === name))
console.error('Contentcomponent "' + name + '" has a component but no catalog entry.');
}
@@ -0,0 +1,135 @@
import {
catalog,
getDescriptor,
CONTENTCOMPONENT_ATTR,
CONTENTCOMPONENT_MAX
} from './catalog.js';
// Runs over the lifetime of the page. Two identical markers in one content therefore get
// two distinct keys, which Vue needs to keep their Teleports apart.
let keyCounter = 0;
// One log line per problem, not one per render.
const reported = new Set();
function report(message)
{
if (reported.has(message))
return;
reported.add(message);
console.error('Contentcomponent: ' + message);
}
/**
* Turns one attribute string into the declared type. Returns undefined on a bad value.
*/
function coerce(raw, spec)
{
switch (spec.type)
{
case 'number':
{
const value = Number(raw);
return (raw === '' || Number.isNaN(value)) ? undefined : value;
}
case 'boolean':
return raw === 'true' || raw === '1';
default:
return raw;
}
}
/**
* Reads the props of one marker. Only an attribute named in the schema is read. Every
* other attribute is dropped. This allow-list is the boundary between the content and
* the component.
*/
function readProps(el, descriptor)
{
const props = {};
for (const [name, spec] of Object.entries(descriptor.props || {}))
{
const raw = el.getAttribute('data-' + name);
if (raw === null)
{
if (spec.required)
{
report('"' + descriptor.name + '" misses the required property "' + name + '".');
return null;
}
continue;
}
const value = coerce(raw, spec);
if (value === undefined)
{
report('"' + descriptor.name + '" got the value "' + raw + '" for "' + name
+ '", which is not a ' + spec.type + '.');
return null;
}
props[name] = value;
}
return props;
}
/**
* Finds every marker below root and returns the ones that can be rendered.
* An unknown name or a bad property is logged and skipped, never shown to a reader.
*
* @param {Element} root
* @return {Array<{el: Element, key: string, name: string, props: Object}>}
*/
export function scanContentcomponents(root)
{
if (!root)
return [];
const elements = Array.from(root.querySelectorAll('[' + CONTENTCOMPONENT_ATTR + ']'));
if (elements.length > CONTENTCOMPONENT_MAX)
{
report('a content holds ' + elements.length + ' components. Only the first '
+ CONTENTCOMPONENT_MAX + ' are rendered.');
}
const markers = [];
for (const el of elements.slice(0, CONTENTCOMPONENT_MAX))
{
const name = el.getAttribute(CONTENTCOMPONENT_ATTR);
const descriptor = getDescriptor(name);
if (!descriptor)
{
report('"' + name + '" is not in the catalog. Known: '
+ catalog.map(entry => entry.name).join(', ') + '.');
continue;
}
const props = readProps(el, descriptor);
if (props === null)
continue;
// A fresh marker gets a key and loses its inner HTML. Teleport adds its children
// and keeps the existing ones, so a fallback text would stay under the component.
// A marker that already carries a key holds a mounted component. Do not clear it.
if (!el.dataset.fhcComponentKey)
{
el.dataset.fhcComponentKey = String(++keyCounter);
el.innerHTML = '';
}
markers.push({
el: el,
key: el.dataset.fhcComponentKey,
name: name,
props: props
});
}
return markers;
}
@@ -256,6 +256,7 @@ export default {
<div id="FhcCarouselContainer" style="height: 100%;" ref="carousel" class="carousel slide fhc-carousel ms-2" data-bs-interval="false">
<div class="carousel-inner" ref="carouselInner" style="height: 100%; max-width: 100%;">
<!-- No contentcomponent-host on purpose: the carousel shows many teasers, and every component would start its own fetch. -->
<div ref="carouselItems" v-for="(news, index) in newsList" class="carousel-item fhc-news-card-item" style="overflow-y: auto; overflow-x: hidden; height: 100%;" :id="'card-'+news.news_id" v-html="news.content_obj.content"/>
</div>
<button @click="setPrev" style="z-index: 100; overflow: hidden; margin-left: 4px; width:35px;" data-bs-target="#FhcCarouselContainer" class="carousel-control-prev" type="button">