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 */`
+ `
+};
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 */`
-
-
-
-
-
-
-
- {{ node.data.name }}
-
-
- {{ node.data.name }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
`
};