From e91f8e5ca4e6fada3ada5917b78814955bf89445 Mon Sep 17 00:00:00 2001 From: chfhtw Date: Mon, 11 May 2026 09:27:54 +0200 Subject: [PATCH] Replace component and endpoint for Verband --- application/config/menubuilder.php | 22 ++ .../controllers/api/frontend/v1/Menu.php | 58 +++ public/js/api/factory/menu.js | 41 ++ public/js/components/Base/Menu.js | 362 ++++++++++++++++++ public/js/components/Base/Menu/Entry.js | 50 +++ .../js/components/Stv/Studentenverwaltung.js | 4 +- .../Stv/Studentenverwaltung/Verband.js | 358 +---------------- 7 files changed, 555 insertions(+), 340 deletions(-) create mode 100644 application/config/menubuilder.php create mode 100644 application/controllers/api/frontend/v1/Menu.php create mode 100644 public/js/api/factory/menu.js create mode 100644 public/js/components/Base/Menu.js create mode 100644 public/js/components/Base/Menu/Entry.js diff --git a/application/config/menubuilder.php b/application/config/menubuilder.php new file mode 100644 index 000000000..64337a691 --- /dev/null +++ b/application/config/menubuilder.php @@ -0,0 +1,22 @@ +. + */ + +if (!defined('BASEPATH')) exit('No direct script access allowed'); + +$config['stv'] = "menu/StvMenuLib"; diff --git a/application/controllers/api/frontend/v1/Menu.php b/application/controllers/api/frontend/v1/Menu.php new file mode 100644 index 000000000..5ad97f403 --- /dev/null +++ b/application/controllers/api/frontend/v1/Menu.php @@ -0,0 +1,58 @@ +. + */ + +if (! defined('BASEPATH')) exit('No direct script access allowed'); + +/** + * This controller operates between (interface) the JS (GUI) and the back-end + * Provides data to the ajax get calls about menues + * This controller works with JSON calls on the HTTP GET or POST and the output is always JSON + */ +class Menu extends FHCAPI_Controller +{ + public function __construct() + { + $permissions = []; + $router = load_class('Router'); + // TODO(chris): permission + $permissions[$router->method] = ['admin:r', 'assistenz:r']; + parent::__construct($permissions); + + // Load Config + $this->config->load('menubuilder'); + } + + /** + * @param string $method + * @param array $params (optional) + * + * @return void + */ + public function _remap($method, $params = []) + { + $this->load->library($this->config->item($method), null, 'menulib'); + + if (!$this->menulib) + show_404(); + + $submenu = $this->menulib->build($params); + + $this->terminateWithSuccess($submenu); + } +} diff --git a/public/js/api/factory/menu.js b/public/js/api/factory/menu.js new file mode 100644 index 000000000..d8b1b8635 --- /dev/null +++ b/public/js/api/factory/menu.js @@ -0,0 +1,41 @@ +/** + * Copyright (C) 2026 fhcomplete.org + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +export default { + get(config, path = '') { + return { + method: 'get', + url: '/api/frontend/v1/menu/' + config + '/' + path + }; + }, + // TODO(chris): handle favorites per config + favorites: { + get() { + return { + method: 'get', + url: 'api/frontend/v1/stv/favorites' + }; + }, + set(favorites) { + return { + method: 'post', + url: 'api/frontend/v1/stv/favorites/set', + params: { favorites } + }; + } + } +}; \ No newline at end of file diff --git a/public/js/components/Base/Menu.js b/public/js/components/Base/Menu.js new file mode 100644 index 000000000..64f1bc4c9 --- /dev/null +++ b/public/js/components/Base/Menu.js @@ -0,0 +1,362 @@ +import MenuEntry from './Menu/Entry.js'; + +import dragClick from '../../directives/dragClick.js'; + +import ApiMenu from '../../api/factory/menu.js'; + +export default { + components: { + PvTreetable: primevue.treetable, + PvColumn: primevue.column, + MenuEntry + }, + directives: { + dragClick + }, + emits: [ + 'selectEntry', + 'drop' + ], + props: { + config: { + type: String, + required: true, + }, + preselectedKey: { + type: String, + default: null + } + }, + data() { + return { + loading: true, + nodes: [], + selectedKey: [], + expandedKeys: {}, + filters: {}, // TODO(chris): filter only 1st level? + favorites: {on: false, list: []} + } + }, + computed: { + filteredNodes() { + if (this.favorites.on) + return this.nodes.filter(node => this.favorites.list.includes(node.data.path)); + + return this.nodes; + } + }, + watch: { + preselectedKey(newVal, oldVal) { + if (newVal !== oldVal) { + this.setPreselection(); + } + } + }, + methods: { + reloadNodesWithProp(prop, nodes = undefined) { + if (!nodes) + nodes = this.nodes; + + nodes.forEach(node => { + if (node.data[prop]) { + // reload + delete node.children; + this.onExpandTreeNode(node); + } else if (node.children) { + this.reloadNodesWithProp(prop, node.children); + } + }); + }, + findNodeByKey(key, arr) { + if (!arr) + arr = this.nodes; + let res = arr.filter(n => n.key == key); + if (res.length) + return res.pop(); + res = arr.map(n => n.children ? this.findNodeByKey(key, n.children) : null).filter(a => a); + if (res.length) + return res.pop(); + return null; + }, + async onExpandTreeNode(node) { + if (!node.children) { + if (node.data.path) { + /** + * NOTE(chris): activeEl is for keyboard navigation to + * prevent the focus jumping down to the next parent + * instead of the current submenu entry (which is not yet + * loaded) + */ + let activeEl = null; + this.$nextTick(() => { + this.$nextTick(() => { + activeEl = document.activeElement; + }); + }); + this.loading = true; + + return this.$api + .call(ApiMenu.get(this.config, node.data.path)) + .then(result => { + const subNodes = result.data.map(this.mapResultToTreeData); + const realNode = this.findNodeByKey(node.key); + if (realNode) + realNode.children = subNodes; + else + node.children = subNodes; // NOTE(chris): fallback should never be the case + + this.$nextTick(() => { + if (activeEl != document.activeElement) + return; + + let treeitem = this.$refs.tree.$el.querySelector('[data-tree-item-key="' + node.key + '"]'); + if (!treeitem) + return; + + treeitem = treeitem.closest('[role="row"]'); + + if (!treeitem) + return; + + treeitem.dispatchEvent(new KeyboardEvent('keydown', { + code: 'ArrowDown', + key: 'ArrowDown' + })); + }); + + this.loading = false; + }) + .catch(this.$fhcAlert.handleSystemError); + } + } + }, + onSelectTreeNode(node) { + this.$emit('selectEntry', node.data); + }, + mapNodesToNoSemReloadNodes(result, node) { + if (node.data.no_sem_reload) + result.push(node); + if (node.children) + result = node.children.reduce(this.mapNodesToNoSemReloadNodes, result); + return result; + }, + mapResultToTreeData(el) { + const cp = { + key: ("" + el.path).replace(/\//g, '-'), + data: el, + label: el.name // TODO(chris): phrase + }; + + if (el.children) + cp.children = el.children.map(this.mapResultToTreeData); + else + cp.leaf = el.leaf || false; + + return cp; + }, + async setPreselection() + { + if (!this.preselectedKey) + { + this.selectedKey = null; + return; + } + + let rawKey = this.preselectedKey + + if (!rawKey || typeof rawKey !== 'string') + return; + + const parts = this.preselectedKey.split('/'); + let currentKey = parts[0]; + let currentNode = this.findNodeByKey(currentKey); + + if (!currentNode) + return; + + if(this.selectedKey) + { + const currentSelectedKey = Object.keys(this.selectedKey).find(Boolean); + if (currentSelectedKey) { + if (currentSelectedKey == currentKey) + return; + /** + * Do not select a new entry if the current is a child of the new one. + * This happens if a child entry of a new stg is selected and the router + * tries to select the stg root entry (because subtrees do not have + * routes yet) + */ + const isChild = this.findNodeByKey( + currentSelectedKey, + currentNode.children || [] + ); + if (isChild) + return; + } + } + + for (let i = 1; i < parts.length; i++) + { + this.expandedKeys[currentNode.key] = true; + + await this.onExpandTreeNode(currentNode); + + currentKey += '-' + parts[i]; + currentNode = this.findNodeByKey(currentKey); + + if (!currentNode) + { + return; + } + } + + this.selectedKey = {[currentNode.key]: true}; + this.onSelectTreeNode(currentNode); + }, + async toggleTreeNode(node) { + if (this.expandedKeys[node.key]) { + delete this.expandedKeys[node.key]; + } else if (!node.leaf) { + await this.onExpandTreeNode(node); + this.expandedKeys[node.key] = true; + } + }, + filterFav() { + this.favorites.on = !this.favorites.on; + this.$api + .call(ApiMenu.favorites.set( + JSON.stringify(this.favorites) + )); + }, + markFav(key) { + let index = this.favorites.list.indexOf(key.data.path + ''); + + if (index != -1) { + this.favorites.list.splice(index, 1); + } else { + this.favorites.list.push(key.data.path + ''); + } + + this.$api + .call(ApiMenu.favorites.set( + JSON.stringify(this.favorites) + )); + }, + unsetFavFocus(e) { + if (e.target.dataset?.linkFavAdd !== undefined) { + e.target.tabIndex = -1; + } else { + let items = e.target.querySelectorAll('[data-link-fav-add]:not([tabindex="-1"])'); + items.forEach(el => el.tabIndex = document.activeElement == el ? 0 : -1); + } + }, + setFavFocus(e) { + if (e.target.dataset?.linkFavAdd !== undefined) { + e.target.tabIndex = 0; + } else { + let items = e.target.querySelectorAll('[data-link-fav-add][tabindex="-1"]'); + items.forEach(el => el.tabIndex = 0); + } + } + }, + mounted() { + this.$api + .call(ApiMenu.get(this.config)) + .then(result => { + this.nodes = result.data.map(el => { + el.root = true; + return this.mapResultToTreeData(el); + }); + this.setPreselection(); + this.loading = false; + }) + .catch(this.$fhcAlert.handleSystemError); + + this.$api + .call(ApiMenu.favorites.get()) + .then(result => { + if (result.data) { + this.favorites = JSON.parse(result.data); + } + }) + .catch(this.$fhcAlert.handleSystemError); + }, + template: /* html */` + + + + + + + + + + + ` +}; diff --git a/public/js/components/Base/Menu/Entry.js b/public/js/components/Base/Menu/Entry.js new file mode 100644 index 000000000..b12145356 --- /dev/null +++ b/public/js/components/Base/Menu/Entry.js @@ -0,0 +1,50 @@ +import drop from '../../../directives/drop.js'; + +export default { + directives: { + drop + }, + emits: [ + 'drop' + ], + props: { + node: { + type: Object, + required: true + } + }, + computed: { + name() { + if (Array.isArray(this.node.data.name)) + return this.$p.t(this.node.data.name); + + return this.node.data.name; + }, + title() { + if (!this.node.data.title) + return this.name; + + if (Array.isArray(this.node.data.title)) + return this.$p.t(this.node.data.title); + + return this.node.data.title; + }, + dropConfig() { + if (!this.node.data?.droplink) + return null; + + const allowed = [ ...this.node.data.droplink ]; + const effect = allowed.shift(); + + return { effect, allowed }; + } + }, + template: /* html */` + + {{ name }} + ` +}; diff --git a/public/js/components/Stv/Studentenverwaltung.js b/public/js/components/Stv/Studentenverwaltung.js index 66ff7f891..25fee8a2e 100644 --- a/public/js/components/Stv/Studentenverwaltung.js +++ b/public/js/components/Stv/Studentenverwaltung.js @@ -273,10 +273,10 @@ export default { }, onSelectVerband({ link, studiengang_kz, semester, orgform_kurzbz }) { let urlpath = String(link); - if (!urlpath.match(/\/prestudent/)) + /*if (!urlpath.match(/\/prestudent/)) { urlpath = 'CURRENT_SEMESTER' + '/' + urlpath; - } + }*/ this.$refs.stvList.updateUrl(ApiStv.students.verband(urlpath)); this.studiengangKz = studiengang_kz; diff --git a/public/js/components/Stv/Studentenverwaltung/Verband.js b/public/js/components/Stv/Studentenverwaltung/Verband.js index 1ff9d2ed9..e93bc3224 100644 --- a/public/js/components/Stv/Studentenverwaltung/Verband.js +++ b/public/js/components/Stv/Studentenverwaltung/Verband.js @@ -1,17 +1,11 @@ -import drop from '../../../directives/drop.js'; -import dragClick from '../../../directives/dragClick.js'; +import BaseMenu from '../../Base/Menu.js'; import ApiStvGroups from '../../../api/factory/stv/group.js'; import ApiStvDetails from '../../../api/factory/stv/details.js'; export default { components: { - PvTreetable: primevue.treetable, - PvColumn: primevue.column - }, - directives: { - drop, - dragClick + BaseMenu }, inject: { $reloadList: { @@ -33,230 +27,22 @@ export default { 'selectVerband' ], props: { - endpoint: { - type: Object, - required: true, - }, preselectedKey: { type: String, default: null } }, - data() { - return { - loading: true, - nodes: [], - selectedKey: [], - expandedKeys: {}, - filters: {}, // TODO(chris): filter only 1st level? - favorites: {on: false, list: []} - } - }, - computed: { - filteredNodes() { - if (this.favorites.on) - return this.nodes.filter(node => this.favorites.list.includes(node.key)); - - return this.nodes; - }, - noSemReloadNodes() { - return this.nodes.reduce(this.mapNodesToNoSemReloadNodes, []); - } - }, watch: { - 'preselectedKey': function (newVal, oldVal) { - if (newVal !== oldVal) { - this.setPreselection(); - } - }, 'appConfig.number_displayed_past_studiensemester'(newVal, oldVal) { - if (oldVal !== undefined) { - this.noSemReloadNodes.forEach(node => { - delete node.children; - this.onExpandTreeNode(node); - }); + if (oldVal !== undefined && this.$refs.menu) { + this.$refs.menu.reloadNodesWithProp('no_sem_reload'); } } }, methods: { - findNodeByKey(key, arr) { - if (!arr) - arr = this.nodes; - let res = arr.filter(n => n.key == key); - if (res.length) - return res.pop(); - res = arr.map(n => n.children ? this.findNodeByKey(key, n.children) : null).filter(a => a); - if (res.length) - return res.pop(); - return null; - }, - async onExpandTreeNode(node) { - if (!node.children) { - if (node.data.link) { - let activeEl = null; - this.$nextTick(() => { - this.$nextTick(() => { - activeEl = document.activeElement; - }); - }); - this.loading = true; - - return this.$api - .call(this.endpoint.get(node.data.link)) - .then(result => result.data) - .then(result => { - const subNodes = result.map(this.mapResultToTreeData); - const realNode = this.findNodeByKey(node.key); - if (realNode) - realNode.children = subNodes; - else - node.children = subNodes; // NOTE(chris): fallback should never be the case - - let treeitem = this.$refs.tree.$el.querySelector('[data-tree-item-key="' + node.key + '"]'); - treeitem = treeitem.closest('[role="row"]'); - - this.$nextTick(() => { - if (activeEl == document.activeElement) - treeitem.dispatchEvent(new KeyboardEvent('keydown', { - code: 'ArrowDown', - key: 'ArrowDown' - })); - }); - - this.loading = false; - }) - .catch(this.$fhcAlert.handleSystemError); - } - } - }, onSelectTreeNode(node) { - if (node.data.link) - this.$emit('selectVerband', {link: node.data.link, studiengang_kz: node.data.stg_kz, semester: node.data.semester, orgform_kurzbz: node.data.orgform_kurzbz}); - }, - mapNodesToNoSemReloadNodes(result, node) { - if (node.data.no_sem_reload) - result.push(node); - if (node.children) - result = node.children.reduce(this.mapNodesToNoSemReloadNodes, result); - return result; - }, - mapResultToTreeData(el) { - const cp = { - key: ("" + el.link).replace('/', '-'), - data: el, - label: el.name - }; - - if (el.children) - cp.children = el.children.map(this.mapResultToTreeData); - else - cp.leaf = el.leaf || false; - - return cp; - }, - filterFav() { - this.favorites.on = !this.favorites.on; - this.$api - .call(this.endpoint.favorites.set( - JSON.stringify(this.favorites) - )); - }, - markFav(key) { - let index = this.favorites.list.indexOf(key.data.link + ''); - - if (index != -1) { - this.favorites.list.splice(index, 1); - } else { - this.favorites.list.push(key.data.link + ''); - } - - this.$api - .call(this.endpoint.favorites.set( - JSON.stringify(this.favorites) - )); - }, - unsetFavFocus(e) { - if (e.target.dataset?.linkFavAdd !== undefined) { - e.target.tabIndex = -1; - } else { - let items = e.target.querySelectorAll('[data-link-fav-add]:not([tabindex="-1"])'); - items.forEach(el => el.tabIndex = document.activeElement == el ? 0 : -1); - } - }, - setFavFocus(e) { - if (e.target.dataset?.linkFavAdd !== undefined) { - e.target.tabIndex = 0; - } else { - let items = e.target.querySelectorAll('[data-link-fav-add][tabindex="-1"]'); - items.forEach(el => el.tabIndex = 0); - } - }, - async setPreselection() - { - if (!this.preselectedKey) - { - this.selectedKey = null; - return; - } - - let rawKey = this.preselectedKey - - if (!rawKey || typeof rawKey !== 'string') - return; - - const parts = this.preselectedKey.split('/'); - let currentKey = parts[0]; - let currentNode = this.findNodeByKey(currentKey); - - if (!currentNode) - return; - - if(this.selectedKey) - { - const currentSelectedKey = Object.keys(this.selectedKey).find(Boolean); - if (currentSelectedKey) { - if (currentSelectedKey == currentKey) - return; - /** - * Do not select a new entry if the current is a child of the new one. - * This happens if a child entry of a new stg is selected and the router - * tries to select the stg root entry (because subtrees do not have - * routes yet) - */ - const isChild = this.findNodeByKey( - currentSelectedKey, - currentNode.children || [] - ); - if (isChild) - return; - } - } - - for (let i = 1; i < parts.length; i++) - { - this.expandedKeys[currentNode.key] = true; - - await this.onExpandTreeNode(currentNode); - - currentKey += '-' + parts[i]; - currentNode = this.findNodeByKey(currentKey); - - if (!currentNode) - { - return; - } - } - - this.selectedKey = {[currentNode.key]: true}; - this.onSelectTreeNode(currentNode); - }, - async toggleTreeNode(node) { - if (this.expandedKeys[node.key]) { - delete this.expandedKeys[node.key]; - } else if (!node.leaf) { - await this.onExpandTreeNode(node); - this.expandedKeys[node.key] = true; - } + if (node.link) + this.$emit('selectVerband', {link: node.link, studiengang_kz: node.stg_kz, semester: node.semester, orgform_kurzbz: node.orgform_kurzbz}); }, getStudentAjaxId(student) { let res = student.id; @@ -264,23 +50,22 @@ export default { res += ' (' + student.vorname + ' ' + student.nachname + ')'; return res; }, - dropStudents(node, students) { - const data = node.data; - + onDrop({ drag, drop }) { let endpoint; - if (data.gruppe_kurzbz) { - endpoint = students.map(student => [ + + if (drop.gruppe_kurzbz) { + endpoint = drag.map(student => [ this.getStudentAjaxId(student), ApiStvGroups.add( student.id, - data.gruppe_kurzbz, + drop.gruppe_kurzbz, this.currentSemester ) ]); } else { - const { semester, verband, gruppe } = data; + const { semester, verband, gruppe } = drop; const params = { semester, verband, gruppe }; - endpoint = students.map(student => [ + endpoint = drag.map(student => [ this.getStudentAjaxId(student), ApiStvDetails.saveStudent( student.id, @@ -296,117 +81,14 @@ export default { .catch(this.$fhcAlert.handleSystemError); } }, - mounted() { - this.$api - .call(this.endpoint.get()) - .then(result => { - this.nodes = result.data.map(el => { - el.root = true; - return this.mapResultToTreeData(el); - }); - this.setPreselection(); - this.loading = false; - }) - .catch(this.$fhcAlert.handleSystemError); - - this.$api - .call(this.endpoint.favorites.get()) - .then(result => { - if (result.data) { - this.favorites = JSON.parse(result.data); - } - }) - .catch(this.$fhcAlert.handleSystemError); - }, template: /* html */`
- - - - - - - - - - - +
` };