From 9f29a7798a2feb864c83b39862bee4800472cbdc Mon Sep 17 00:00:00 2001 From: Johann Hoffmann Date: Tue, 1 Sep 2026 15:31:55 +0200 Subject: [PATCH] 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; --- .../controllers/api/frontend/v1/Cms.php | 282 +++++++++++++++++- .../controllers/api/frontend/v1/CmsAdmin.php | 45 +++ .../models/content/Contentchild_model.php | 62 ++++ application/models/content/Dms_model.php | 202 +++++++++++++ application/models/person/Person_model.php | 52 ++++ .../models/ressource/Mitarbeiter_model.php | 25 ++ cms/tinymce_dms.php | 66 +--- public/js/api/factory/cms.js | 40 +++ public/js/api/factory/cmsadmin.js | 22 ++ .../Cis/Cms/Content_types/General.js | 9 +- .../Cis/Cms/Content_types/News_content.js | 1 + .../Cms/Content_types/Raum_contentmittitel.js | 7 +- public/js/components/Cis/Cms/News.js | 1 + public/js/components/Cms/Form/FieldWysiwyg.js | 92 ++++-- public/js/components/Cms/Tabs/Content.js | 14 + .../Contentcomponents/ContentchildMenu.js | 84 ++++++ .../Contentcomponents/ContentcomponentHost.js | 74 +++++ .../Contentcomponents/DmsDokumente.js | 56 ++++ .../components/Contentcomponents/DmsListe.js | 66 ++++ .../Contentcomponents/DokumentListe.js | 59 ++++ .../Contentcomponents/OePersonen.js | 78 +++++ .../Contentcomponents/PersonBlock.js | 53 ++++ .../Contentcomponents/PersonKontakt.js | 77 +++++ .../js/components/Contentcomponents/README.md | 113 +++++++ .../components/Contentcomponents/catalog.js | 122 ++++++++ .../Contentcomponents/components.js | 33 ++ .../js/components/Contentcomponents/scan.js | 135 +++++++++ public/js/components/DashboardWidget/News.js | 1 + 28 files changed, 1786 insertions(+), 85 deletions(-) create mode 100644 public/js/components/Contentcomponents/ContentchildMenu.js create mode 100644 public/js/components/Contentcomponents/ContentcomponentHost.js create mode 100644 public/js/components/Contentcomponents/DmsDokumente.js create mode 100644 public/js/components/Contentcomponents/DmsListe.js create mode 100644 public/js/components/Contentcomponents/DokumentListe.js create mode 100644 public/js/components/Contentcomponents/OePersonen.js create mode 100644 public/js/components/Contentcomponents/PersonBlock.js create mode 100644 public/js/components/Contentcomponents/PersonKontakt.js create mode 100644 public/js/components/Contentcomponents/README.md create mode 100644 public/js/components/Contentcomponents/catalog.js create mode 100644 public/js/components/Contentcomponents/components.js create mode 100644 public/js/components/Contentcomponents/scan.js diff --git a/application/controllers/api/frontend/v1/Cms.php b/application/controllers/api/frontend/v1/Cms.php index b8937a1f4..bba1a1861 100644 --- a/application/controllers/api/frontend/v1/Cms.php +++ b/application/controllers/api/frontend/v1/Cms.php @@ -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; + } + } diff --git a/application/controllers/api/frontend/v1/CmsAdmin.php b/application/controllers/api/frontend/v1/CmsAdmin.php index 3c26986da..c40f2b7dd 100644 --- a/application/controllers/api/frontend/v1/CmsAdmin.php +++ b/application/controllers/api/frontend/v1/CmsAdmin.php @@ -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()); + } + } diff --git a/application/models/content/Contentchild_model.php b/application/models/content/Contentchild_model.php index e9a1ccae5..04d9f8350 100644 --- a/application/models/content/Contentchild_model.php +++ b/application/models/content/Contentchild_model.php @@ -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 diff --git a/application/models/content/Dms_model.php b/application/models/content/Dms_model.php index e9db6d903..afcd1ffe9 100644 --- a/application/models/content/Dms_model.php +++ b/application/models/content/Dms_model.php @@ -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=, 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); + } } \ No newline at end of file diff --git a/application/models/person/Person_model.php b/application/models/person/Person_model.php index c893c51de..dd65e6c74 100644 --- a/application/models/person/Person_model.php +++ b/application/models/person/Person_model.php @@ -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'); diff --git a/application/models/ressource/Mitarbeiter_model.php b/application/models/ressource/Mitarbeiter_model.php index d8bbd7d63..4318819f5 100644 --- a/application/models/ressource/Mitarbeiter_model.php +++ b/application/models/ressource/Mitarbeiter_model.php @@ -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 * diff --git a/cms/tinymce_dms.php b/cms/tinymce_dms.php index 56f0915f4..c21ed4604 100644 --- a/cms/tinymce_dms.php +++ b/cms/tinymce_dms.php @@ -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=' } + * 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=' } - * 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; diff --git a/public/js/api/factory/cms.js b/public/js/api/factory/cms.js index 59ad4af5a..6c0aa00c5 100644 --- a/public/js/api/factory/cms.js +++ b/public/js/api/factory/cms.js @@ -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 { diff --git a/public/js/api/factory/cmsadmin.js b/public/js/api/factory/cmsadmin.js index 012ec8ed5..711f91e69 100644 --- a/public/js/api/factory/cmsadmin.js +++ b/public/js/api/factory/cmsadmin.js @@ -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', diff --git a/public/js/components/Cis/Cms/Content_types/General.js b/public/js/components/Cis/Cms/Content_types/General.js index cbc4224f9..88436d29b 100644 --- a/public/js/components/Cis/Cms/Content_types/General.js +++ b/public/js/components/Cis/Cms/Content_types/General.js @@ -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*/ `
-
+

Content was not found

`, diff --git a/public/js/components/Cis/Cms/Content_types/News_content.js b/public/js/components/Cis/Cms/Content_types/News_content.js index a17e8592a..deaf90288 100644 --- a/public/js/components/Cis/Cms/Content_types/News_content.js +++ b/public/js/components/Cis/Cms/Content_types/News_content.js @@ -134,6 +134,7 @@ export default { template: /*html*/ `
+

Content was not found

diff --git a/public/js/components/Cis/Cms/Content_types/Raum_contentmittitel.js b/public/js/components/Cis/Cms/Content_types/Raum_contentmittitel.js index 902a05aca..c98a0d9b5 100644 --- a/public/js/components/Cis/Cms/Content_types/Raum_contentmittitel.js +++ b/public/js/components/Cis/Cms/Content_types/Raum_contentmittitel.js @@ -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 {
-
+

Content was not found

`, }; diff --git a/public/js/components/Cis/Cms/News.js b/public/js/components/Cis/Cms/News.js index 7c73bcb67..d272a36b6 100644 --- a/public/js/components/Cis/Cms/News.js +++ b/public/js/components/Cis/Cms/News.js @@ -117,6 +117,7 @@ export default { >
+
diff --git a/public/js/components/Cms/Form/FieldWysiwyg.js b/public/js/components/Cms/Form/FieldWysiwyg.js index 823c1d9f6..c2b696256 100644 --- a/public/js/components/Cms/Form/FieldWysiwyg.js +++ b/public/js/components/Cms/Form/FieldWysiwyg.js @@ -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'); diff --git a/public/js/components/Cms/Tabs/Content.js b/public/js/components/Cms/Tabs/Content.js index e36a3da90..97a84207d 100644 --- a/public/js/components/Cms/Tabs/Content.js +++ b/public/js/components/Cms/Tabs/Content.js @@ -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 { +
+ 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". +
+ { 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*/ ` +
+
...
+
+ Das Menü konnte nicht geladen werden. +
+ +
    +
  • + + {{ kind.titel }} + +
  • +
+
+ ` +}; diff --git a/public/js/components/Contentcomponents/ContentcomponentHost.js b/public/js/components/Contentcomponents/ContentcomponentHost.js new file mode 100644 index 000000000..c0366db42 --- /dev/null +++ b/public/js/components/Contentcomponents/ContentcomponentHost.js @@ -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 `
`. 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*/ ` +
+ + + + ` +}; diff --git a/public/js/components/Contentcomponents/DmsDokumente.js b/public/js/components/Contentcomponents/DmsDokumente.js new file mode 100644 index 000000000..efc7efbe7 --- /dev/null +++ b/public/js/components/Contentcomponents/DmsDokumente.js @@ -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*/ ` +
+
...
+
+ Die Dokumentenliste konnte nicht geladen werden. +
+ + +
+ ` +}; diff --git a/public/js/components/Contentcomponents/DmsListe.js b/public/js/components/Contentcomponents/DmsListe.js new file mode 100644 index 000000000..5bc829d43 --- /dev/null +++ b/public/js/components/Contentcomponents/DmsListe.js @@ -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*/ ` +
+
...
+
+ Die Dokumentenliste konnte nicht geladen werden. +
+ + +
+ ` +}; diff --git a/public/js/components/Contentcomponents/DokumentListe.js b/public/js/components/Contentcomponents/DokumentListe.js new file mode 100644 index 000000000..c0a5c71a1 --- /dev/null +++ b/public/js/components/Contentcomponents/DokumentListe.js @@ -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*/ ` + + ` +}; diff --git a/public/js/components/Contentcomponents/OePersonen.js b/public/js/components/Contentcomponents/OePersonen.js new file mode 100644 index 000000000..81c70aafb --- /dev/null +++ b/public/js/components/Contentcomponents/OePersonen.js @@ -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*/ ` +
+
...
+
+ Die Personen konnten nicht geladen werden. +
+
+ Keine Personen zugeordnet. +
+ +
+ ` +}; diff --git a/public/js/components/Contentcomponents/PersonBlock.js b/public/js/components/Contentcomponents/PersonBlock.js new file mode 100644 index 000000000..bce852f4a --- /dev/null +++ b/public/js/components/Contentcomponents/PersonBlock.js @@ -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*/ ` +
+ +
+ + {{ name }} + + {{ name }} +
+
{{ funktion }}
+
+ T: {{ telefon }} +
+
R: {{ ort }}
+
+ E: {{ email }} +
+
+ ` +}; diff --git a/public/js/components/Contentcomponents/PersonKontakt.js b/public/js/components/Contentcomponents/PersonKontakt.js new file mode 100644 index 000000000..09264863e --- /dev/null +++ b/public/js/components/Contentcomponents/PersonKontakt.js @@ -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*/ ` +
+
...
+
+ Die Person konnte nicht geladen werden. +
+ +
+ ` +}; diff --git a/public/js/components/Contentcomponents/README.md b/public/js/components/Contentcomponents/README.md new file mode 100644 index 000000000..ebdfdc717 --- /dev/null +++ b/public/js/components/Contentcomponents/README.md @@ -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 +
+``` + +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-`. +- `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. + diff --git a/public/js/components/Contentcomponents/catalog.js b/public/js/components/Contentcomponents/catalog.js new file mode 100644 index 000000000..865bcf7c1 --- /dev/null +++ b/public/js/components/Contentcomponents/catalog.js @@ -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: + * + *
+ * + * 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; +} diff --git a/public/js/components/Contentcomponents/components.js b/public/js/components/Contentcomponents/components.js new file mode 100644 index 000000000..bca04bb2e --- /dev/null +++ b/public/js/components/Contentcomponents/components.js @@ -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.'); +} diff --git a/public/js/components/Contentcomponents/scan.js b/public/js/components/Contentcomponents/scan.js new file mode 100644 index 000000000..cf1be66f0 --- /dev/null +++ b/public/js/components/Contentcomponents/scan.js @@ -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; +} diff --git a/public/js/components/DashboardWidget/News.js b/public/js/components/DashboardWidget/News.js index 103bc4fa1..46904ff99 100644 --- a/public/js/components/DashboardWidget/News.js +++ b/public/js/components/DashboardWidget/News.js @@ -256,6 +256,7 @@ export default {