mirror of
https://github.com/FH-Complete/FHC-Core.git
synced 2026-10-12 15:04:04 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user