From b378649b06820999fc3ba0280381bd5a9706e75c Mon Sep 17 00:00:00 2001 From: chfhtw Date: Tue, 2 Sep 2025 16:04:02 +0200 Subject: [PATCH 01/10] add validation "is_in_db" --- application/helpers/hlp_common_helper.php | 28 +++++++++++++++++++ .../language/english/form_validation_lang.php | 1 + 2 files changed, 29 insertions(+) diff --git a/application/helpers/hlp_common_helper.php b/application/helpers/hlp_common_helper.php index 00c0a1b93..edbde5272 100644 --- a/application/helpers/hlp_common_helper.php +++ b/application/helpers/hlp_common_helper.php @@ -515,3 +515,31 @@ function has_permissions_for_stg($studiengang_kz, $permissions = '') return false; } + +/** + * check if an entry exists in the database + */ +function is_in_db($key, $model = '') +{ + if (!$model) + return false; + + $field = strstr($model, ":"); + if ($field) { + $model = strstr($model, ":", true); + $field = substr($field, 1); + } + + $CI =& get_instance(); + $CI->load->model($model, $model); + + if ($field) { + $result = $CI->$model->loadWhere([ + $field => $key + ]); + } else { + $result = $CI->$model->load($key); + } + + return (isSuccess($result) && hasData($result)); +} diff --git a/application/language/english/form_validation_lang.php b/application/language/english/form_validation_lang.php index b8918a721..2777cd05e 100644 --- a/application/language/english/form_validation_lang.php +++ b/application/language/english/form_validation_lang.php @@ -41,3 +41,4 @@ if (!defined('BASEPATH')) exit('No direct script access allowed'); $lang['form_validation_has_write_permissions'] = 'You have no rights to edit {field} field.'; $lang['form_validation_is_valid_date'] = 'The date format is invalid or out of range.'; $lang['form_validation_has_permissions_for_stg'] = 'You have no rights for stg {field}.'; +$lang['form_validation_is_in_db'] = '{field} does not exist.'; From eae79e9f5eedff797babc5f1f04ab81fa1414a84 Mon Sep 17 00:00:00 2001 From: chfhtw Date: Tue, 2 Sep 2025 16:04:47 +0200 Subject: [PATCH 02/10] CSS fix: primevue autocomplete inside bootstrap input-group --- public/css/components/primevue.css | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/public/css/components/primevue.css b/public/css/components/primevue.css index 5949d6ecc..ac1950334 100644 --- a/public/css/components/primevue.css +++ b/public/css/components/primevue.css @@ -5690,3 +5690,32 @@ outline: 0; box-shadow: 0 0 0 .25rem rgba(13,110,253,.25); } + +/* input-group */ +/* autocomplete */ +.input-group:not(.has-validation) > .dropdown-toggle:nth-last-child(n+3) > .p-autocomplete-input, +.input-group:not(.has-validation) > .form-floating:not(:last-child) > .form-control > .p-autocomplete-input, +.input-group:not(.has-validation) > .form-floating:not(:last-child) > .form-select > .p-autocomplete-input, +.input-group:not(.has-validation) > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating) > .p-autocomplete-input, +.input-group:not(.has-validation) > .dropdown-toggle:nth-last-child(n+3) > .p-autocomplete > .p-autocomplete-input, +.input-group:not(.has-validation) > .form-floating:not(:last-child) > .form-control > .p-autocomplete > .p-autocomplete-input, +.input-group:not(.has-validation) > .form-floating:not(:last-child) > .form-select > .p-autocomplete > .p-autocomplete-input, +.input-group:not(.has-validation) > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating) > .p-autocomplete > .p-autocomplete-input { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.input-group > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) > .p-autocomplete-input, +.input-group > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) > .p-autocomplete > .p-autocomplete-input { + margin-left: calc(var(--bs-border-width) * -1); + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.p-inputtext.is-invalid:enabled:hover { + border-color: var(--bs-form-invalid-border-color); +} +.p-inputtext.is-invalid:enabled:focus, +.was-validated .p-inputtext:invalid:focus { + border-color: var(--bs-form-invalid-border-color); + box-shadow: 0 0 0 .25rem rgba(var(--bs-danger-rgb),.25); +} From 86641ea02d82c84df9d6b4e03e00b4b2456b3fbe Mon Sep 17 00:00:00 2001 From: chfhtw Date: Tue, 2 Sep 2025 16:06:04 +0200 Subject: [PATCH 03/10] StV: Lehrverband- & Special-groups --- .../api/frontend/v1/stv/Config.php | 6 +- .../api/frontend/v1/stv/Gruppen.php | 192 ++++++++++++-- .../api/frontend/v1/stv/Lehrverband.php | 63 +++++ public/js/api/factory/stv/group.js | 21 ++ public/js/api/factory/stv/lehrverband.js | 31 +++ .../Stv/Studentenverwaltung/Details/Groups.js | 216 ++++++++++++++++ .../Details/Groups/List.js | 171 +++++++++++++ .../Studentenverwaltung/Details/Groups/Lvb.js | 242 ++++++++++++++++++ .../Details/Groups/Special.js | 97 +++++++ .../Details/Gruppen/Gruppen.js | 187 -------------- system/phrasesupdate.php | 161 ++++++++++++ 11 files changed, 1177 insertions(+), 210 deletions(-) create mode 100644 application/controllers/api/frontend/v1/stv/Lehrverband.php create mode 100644 public/js/api/factory/stv/lehrverband.js create mode 100644 public/js/components/Stv/Studentenverwaltung/Details/Groups.js create mode 100644 public/js/components/Stv/Studentenverwaltung/Details/Groups/List.js create mode 100644 public/js/components/Stv/Studentenverwaltung/Details/Groups/Lvb.js create mode 100644 public/js/components/Stv/Studentenverwaltung/Details/Groups/Special.js delete mode 100644 public/js/components/Stv/Studentenverwaltung/Details/Gruppen/Gruppen.js diff --git a/application/controllers/api/frontend/v1/stv/Config.php b/application/controllers/api/frontend/v1/stv/Config.php index 2fb436384..5898e1094 100644 --- a/application/controllers/api/frontend/v1/stv/Config.php +++ b/application/controllers/api/frontend/v1/stv/Config.php @@ -110,7 +110,7 @@ class Config extends FHCAPI_Controller ]; $result['groups'] = [ 'title' => $this->p->t('stv', 'tab_groups'), - 'component' => './Stv/Studentenverwaltung/Details/Gruppen.js' + 'component' => './Stv/Studentenverwaltung/Details/Groups.js' ]; $result['messages'] = [ 'title' => $this->p->t('stv', 'tab_messages'), @@ -203,6 +203,10 @@ class Config extends FHCAPI_Controller 'additionalCols' => [] ] ]; + $result['groups'] = [ + 'title' => $this->p->t('stv', 'tab_groups'), + 'component' => './Stv/Studentenverwaltung/Details/Groups.js' + ]; $result['status'] = [ 'title' => 'Status', 'component' => './Stv/Studentenverwaltung/Details/MultiStatus.js', diff --git a/application/controllers/api/frontend/v1/stv/Gruppen.php b/application/controllers/api/frontend/v1/stv/Gruppen.php index c30816f2a..c45165b41 100644 --- a/application/controllers/api/frontend/v1/stv/Gruppen.php +++ b/application/controllers/api/frontend/v1/stv/Gruppen.php @@ -9,6 +9,8 @@ class Gruppen extends FHCAPI_Controller public function __construct() { parent::__construct([ + 'add' => ['admin:rw', 'assistenz:rw'], + 'search' => ['admin:r', 'assistenz:r'], 'getGruppen' => ['admin:r', 'assistenz:r'], 'deleteGruppe' => ['admin:rw', 'assistenz:rw'], ]); @@ -18,7 +20,9 @@ class Gruppen extends FHCAPI_Controller // Load language phrases $this->loadPhrases([ - 'ui', 'gruppenmanagement' + 'ui', + 'gruppenmanagement', + 'lehre' ]); // Load models @@ -26,15 +30,140 @@ class Gruppen extends FHCAPI_Controller $this->load->model('organisation/Gruppe_model', 'GruppeModel'); } + public function add() + { + $this->load->library("form_validation"); + + $this->form_validation->set_rules( + 'gruppe_kurzbz', + $this->p->t('gruppenmanagement', 'gruppe'), + 'required|is_in_db[organisation/Gruppe_model]', + [ + 'required' => $this->p->t('ui', 'error_fieldRequired'), + 'is_in_db' => $this->p->t('ui', 'error_fieldNotFound') + ] + ); + $this->form_validation->set_rules( + 'uid', + $this->p->t('ui', 'student_uid'), + 'required|is_in_db[crm/Student_model:student_uid]', + [ + 'required' => $this->p->t('ui', 'error_fieldRequired'), + 'is_in_db' => $this->p->t('ui', 'error_fieldNotFound') + ] + ); + $this->form_validation->set_rules( + 'studiensemester_kurzbz', + $this->p->t('lehre', 'studiensemester'), + 'required|is_in_db[organisation/Studiensemester_model]', + [ + 'required' => $this->p->t('ui', 'error_fieldRequired'), + 'is_in_db' => $this->p->t('ui', 'error_fieldNotFound') + ] + ); + + if (!$this->form_validation->run()) + $this->terminateWithValidationErrors($this->form_validation->error_array()); + + $gruppe_kurzbz = $this->input->post('gruppe_kurzbz'); + $uid = $this->input->post('uid'); + $studiensemester_kurzbz = $this->input->post('studiensemester_kurzbz'); + + $result = $this->BenutzergruppeModel->load([ + $gruppe_kurzbz, + $uid + ]); + $benutzergruppe = $this->getDataOrTerminateWithError($result); + + if ($benutzergruppe) { + $this->terminateWithError( + $this->p->t('gruppenmanagement', 'error_alreadyInGroup', [ + 'uid' => $uid, + 'studiensemester_kurzbz' => current($benutzergruppe)->studiensemester_kurzbz + ]), + self::ERROR_TYPE_GENERAL + ); + } + + $result = $this->BenutzergruppeModel->insert([ + 'uid' => $uid, + 'gruppe_kurzbz' => $gruppe_kurzbz, + 'studiensemester_kurzbz' => $studiensemester_kurzbz, + 'insertamum' => date('c'), + 'insertvon' => getAuthUID() + ]); + + $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess(); + } + + public function search() + { + $query = $this->input->post('query'); + if (!$query) + $this->terminateWithSuccess([]); + + // add query to where clause + $query = strtoupper($query); + $query = $this->GruppeModel->db->escape_like_str($query); + $query = '%' . str_replace(' ', '%', $query) . '%'; + + $this->GruppeModel->db->group_start(); + $this->GruppeModel->db->or_like('UPPER(gruppe_kurzbz)', $query, 'none', false); + $this->GruppeModel->db->or_like('UPPER(bezeichnung)', $query, 'none', false); + $this->GruppeModel->db->or_like('UPPER(beschreibung)', $query, 'none', false); + $this->GruppeModel->db->group_end(); + + // add stg sorting 1 + $studiengang_kz = $this->input->post('studiengang_kz'); + $sort_stg = $studiengang_kz ? "WHEN studiengang_kz = " . $this->GruppeModel->escape($studiengang_kz) . " THEN 0" : ""; + + // add stg sorting 2 + $studiengang_kzs = []; + $result = $this->permissionlib->getSTG_isEntitledFor('admin'); + if ($result) + $studiengang_kzs = array_merge($studiengang_kzs, $result); + $result = $this->permissionlib->getSTG_isEntitledFor('assistenz'); + if ($result) + $studiengang_kzs = array_merge($studiengang_kzs, $result); + + // selects + $this->GruppeModel->addSelect("*"); + $this->GruppeModel->addSelect("CASE + " . $sort_stg . " + WHEN studiengang_kz IN (" . implode(",", $this->GruppeModel->db->escape($studiengang_kzs)) . ") + THEN 1 + ELSE 2 + END AS sort_stg"); + + // ordering + $this->GruppeModel->addOrder("sort_stg"); + $this->GruppeModel->addOrder("sort"); + $this->GruppeModel->addOrder("gruppe_kurzbz"); + + // default where clause & execute + $result = $this->GruppeModel->loadWhere([ + 'lehre' => true, + 'sichtbar' => true, + 'aktiv' => true, + 'direktinskription' => false + ]); + + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + public function getGruppen($student_uid) { - $this->BenutzergruppeModel ->addSelect('gruppe_kurzbz'); - $this->BenutzergruppeModel ->addSelect('bezeichnung'); - $this->BenutzergruppeModel ->addSelect('generiert'); - $this->BenutzergruppeModel ->addSelect('uid'); - $this->BenutzergruppeModel ->addSelect('studiensemester_kurzbz'); - $this->BenutzergruppeModel ->addJoin('public.tbl_gruppe', 'gruppe_kurzbz'); - $this->BenutzergruppeModel-> addOrder('bezeichnung', 'ASC'); + $this->BenutzergruppeModel->addSelect('gruppe_kurzbz'); + $this->BenutzergruppeModel->addSelect('bezeichnung'); + $this->BenutzergruppeModel->addSelect('generiert'); + $this->BenutzergruppeModel->addSelect('uid'); + $this->BenutzergruppeModel->addSelect('studiensemester_kurzbz'); + $this->BenutzergruppeModel->addJoin('public.tbl_gruppe', 'gruppe_kurzbz'); + $this->BenutzergruppeModel->addOrder('bezeichnung', 'ASC'); $result = $this->BenutzergruppeModel->loadWhere( array( @@ -49,29 +178,48 @@ class Gruppen extends FHCAPI_Controller public function deleteGruppe() { - $student_uid = $this->input->post('id'); + $this->load->library("form_validation"); + + $this->form_validation->set_rules( + 'uid', + $this->p->t('person', 'UID'), + 'required', + [ + 'required' => $this->p->t('ui', 'error_fieldRequired') + ] + ); + + $this->form_validation->set_rules( + 'gruppe_kurzbz', + $this->p->t('gruppenmanagement', 'gruppe'), + 'required', + [ + 'required' => $this->p->t('ui', 'error_fieldRequired') + ] + ); + + if (!$this->form_validation->run()) + $this->terminateWithValidationErrors($this->form_validation->error_array()); + + $uid = $this->input->post('uid'); $gruppe_kurzbz = $this->input->post('gruppe_kurzbz'); - //Validate if automatic group generation - $result = $this->GruppeModel-> loadWhere( - array( - 'gruppe_kurzbz' => $gruppe_kurzbz - ) - ); + // Validate if automatic group generation + $result = $this->GruppeModel->loadWhere([ + 'gruppe_kurzbz' => $gruppe_kurzbz + ]); $data = $this->getDataOrTerminateWithError($result); $generation = current($data); - if($generation->generiert) + if ($generation->generiert) { $this->terminateWithError($this->p->t('gruppenmanagement', 'error_deleteGeneratedGroups'), self::ERROR_TYPE_GENERAL); } - $result = $this->BenutzergruppeModel->delete( - array( - 'gruppe_kurzbz' => $gruppe_kurzbz, - 'uid' => $student_uid - ) - ); + $result = $this->BenutzergruppeModel->delete([ + 'gruppe_kurzbz' => $gruppe_kurzbz, + 'uid' => $uid + ]); $data = $this->getDataOrTerminateWithError($result); diff --git a/application/controllers/api/frontend/v1/stv/Lehrverband.php b/application/controllers/api/frontend/v1/stv/Lehrverband.php new file mode 100644 index 000000000..72610dd63 --- /dev/null +++ b/application/controllers/api/frontend/v1/stv/Lehrverband.php @@ -0,0 +1,63 @@ + ['admin:r', 'assistenz:r'], + 'getTree' => ['admin:r', 'assistenz:r'], + 'getSpecialgroups' => ['admin:r', 'assistenz:r'] + ]); + } + + public function hasOrgforms($studiengang_kz) + { + $this->load->model('organisation/Studiengang_model', 'StudiengangModel'); + + $result = $this->StudiengangModel->load($studiengang_kz); + + $data = $this->getDataOrTerminateWithError($result); + if ($data) { + $data = current($data)->mischform; + } + + $this->terminateWithSuccess($data); + } + + public function getTree($studiengang_kz) + { + $this->load->model('organisation/Lehrverband_model', 'LehrverbandModel'); + + $result = $this->LehrverbandModel->loadWhere([ + 'studiengang_kz' => $studiengang_kz, + 'aktiv' => true + ]); + + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } + + public function getSpecialgroups($studiengang_kz) + { + $this->load->model('organisation/Gruppe_model', 'GruppeModel'); + + $where = [ + 'studiengang_kz' => $studiengang_kz, + 'lehre' => true, + 'sichtbar' => true, + 'aktiv' => true, + 'direktinskription' => false + ]; + + $result = $this->GruppeModel->loadWhere($where); + + $data = $this->getDataOrTerminateWithError($result); + + $this->terminateWithSuccess($data); + } +} diff --git a/public/js/api/factory/stv/group.js b/public/js/api/factory/stv/group.js index b395fbf43..18fcb4443 100644 --- a/public/js/api/factory/stv/group.js +++ b/public/js/api/factory/stv/group.js @@ -16,6 +16,27 @@ */ export default { + add(uid, gruppe_kurzbz, studiensemester_kurzbz) { + return { + method: 'post', + url: 'api/frontend/v1/stv/gruppen/add/', + params: { + uid, + gruppe_kurzbz, + studiensemester_kurzbz + } + }; + }, + search(query, studiengang_kz) { + return { + method: 'post', + url: 'api/frontend/v1/stv/gruppen/search/', + params: { + query, + studiengang_kz + } + }; + }, getGruppen(id) { return { method: 'get', diff --git a/public/js/api/factory/stv/lehrverband.js b/public/js/api/factory/stv/lehrverband.js new file mode 100644 index 000000000..676422087 --- /dev/null +++ b/public/js/api/factory/stv/lehrverband.js @@ -0,0 +1,31 @@ +/** + * Copyright (C) 2025 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 { + hasOrgforms(studiengang_kz) { + return { + method: 'get', + url: 'api/frontend/v1/stv/lehrverband/hasOrgforms/' + studiengang_kz + }; + }, + getTree(studiengang_kz) { + return { + method: 'get', + url: 'api/frontend/v1/stv/lehrverband/getTree/' + studiengang_kz + }; + } +}; diff --git a/public/js/components/Stv/Studentenverwaltung/Details/Groups.js b/public/js/components/Stv/Studentenverwaltung/Details/Groups.js new file mode 100644 index 000000000..5b2c6fc73 --- /dev/null +++ b/public/js/components/Stv/Studentenverwaltung/Details/Groups.js @@ -0,0 +1,216 @@ +import GroupsLvb from './Groups/Lvb.js'; +import GroupsSpecial from './Groups/Special.js'; +import GroupsList from './Groups/List.js'; + +import ApiStvGroups from '../../../../api/factory/stv/group.js'; +import ApiStvDetails from '../../../../api/factory/stv/details.js'; + +export default { + name: 'TabGroups', + components: { + GroupsLvb, + GroupsSpecial, + GroupsList + }, + inject: { + $reloadList: { + from: '$reloadList', + required: true + }, + currentSemester: { + form: 'currentSemester', + required: true + } + }, + props: { + modelValue: [Object, Array] + }, + data() { + return { + hasOrgforms: false, + lvbList: [], + specialGroups: [], + selectedOrgform: false, + selectedSemester: false, + selectedVerband: false, + selectedGruppe: false, + multiFormHandler: (form, errors) => { + function _split_errors(result, [uid, errors]) { + const gruppe_kurzbz = []; + const studiensemester_kurzbz = []; + const others = {}; + errors.forEach(error => { + _split_messages(error.messages, gruppe_kurzbz, studiensemester_kurzbz, others); + }); + if (gruppe_kurzbz.length) { + if (!result.formFeedback.gruppe_kurzbz) + result.formFeedback.gruppe_kurzbz = []; + result.formFeedback.gruppe_kurzbz.push(...gruppe_kurzbz); + } + if (studiensemester_kurzbz.length) { + if (!result.formFeedback.studiensemester_kurzbz) + result.formFeedback.studiensemester_kurzbz = []; + result.formFeedback.studiensemester_kurzbz.push(...studiensemester_kurzbz); + } + if (Object.keys(others).length) { + result.toast[uid] = [ + { type: 'validation', messages: others } + ]; + } + return result; + } + function _split_messages(messages, gruppe_kurzbz, studiensemester_kurzbz, others) { + Object.entries(messages).forEach(([field, msg]) => { + if (field == 'gruppe_kurzbz') { + gruppe_kurzbz.push(msg); + } else if (field == 'studiensemester_kurzbz') { + studiensemester_kurzbz.push(msg); + } else { + if (!others[field]) + others[field] = []; + others[field].push(msg); + } + }); + } + const { formFeedback, toast } = Object.entries(errors) + .reduce(_split_errors, { formFeedback: {}, toast: {} }); + + if (formFeedback.gruppe_kurzbz) + formFeedback.gruppe_kurzbz = formFeedback.gruppe_kurzbz + .filter((v,k,a) => a.indexOf(v) == k); + if (formFeedback.studiensemester_kurzbz) + formFeedback.studiensemester_kurzbz = formFeedback.studiensemester_kurzbz + .filter((v,k,a) => a.indexOf(v) == k); + + form.clearValidation(); + if (Object.keys(formFeedback)) { + form.setFeedback(false, formFeedback); + } + + if (Object.keys(toast).length) { + console.log(toast); + this.$api.getErrorHandler().handler.toast(toast); + } + } + }; + }, + computed: { + allAreStudents() { + if (Array.isArray(this.modelValue)) + return this.modelValue.every(ps => ps.uid); + return this.modelValue.uid; + }, + sharedStg() { + if (Array.isArray(this.modelValue)) { + const first = this.modelValue.find(Boolean); + if (this.modelValue.every(ps => ps.studiengang_kz === first.studiengang_kz)) + return first.studiengang_kz; + return false; + } + return this.modelValue.studiengang_kz; + } + }, + methods: { + showNewGroupModal() { + this.$refs.newGroupModal.show() + }, + changeLvb(params) { + let data = { semester: params.semester }; + if (params.verband && params.verband != " ") { + data.verband = params.verband; + if (params.gruppe && params.gruppe != " ") + data.gruppe = params.gruppe; + } + + let endpoint; + + if (Array.isArray(this.modelValue)) { + endpoint = this.modelValue.map(student => [ + student.uid + ' (' + student.vorname + ' ' + student.nachname + ')', + ApiStvDetails.save( + student.prestudent_id, + this.currentSemester, + data + ) + ]); + } else { + endpoint = ApiStvDetails.save( + this.modelValue.prestudent_id, + this.currentSemester, + data + ); + } + this.$api + .call(endpoint) + .then(result => { + this.$fhcAlert.alertSuccess(this.$p.t('ui', 'successSave')); + this.$reloadList(); + }) + .catch(this.$fhcAlert.handleSystemError); + }, + addSpecialGroup(params) { + const gruppe_kurzbz = this.$refs.newGroupModal.value.gruppe_kurzbz || this.$refs.newGroupModal.value; + + if (Array.isArray(this.modelValue)) { + this.$refs.newGroupModal.$refs.form + .call( + this.modelValue.map(student => [ + student.uid + ' (' + student.vorname + ' ' + student.nachname + ')', + ApiStvGroups.add( + student.uid, + gruppe_kurzbz, + this.currentSemester + ) + ]), + { + errorHandling: { + combine: { form: ['validation'] }, + handler: { form: this.multiFormHandler } + } + } + ) + .then(result => { + const successes = result.filter(res => res.status == 'fulfilled'); + if (result.length == successes.length) { + this.$refs.newGroupModal.hide(); + } + if (successes.length) { + this.$fhcAlert.alertSuccess(this.$p.t('gruppenmanagement/groups_added', { n: successes.length })); + this.$refs.list.reload(); + } + }) + .catch(this.$fhcAlert.handleSystemError); + } else { + this.$refs.newGroupModal.$refs.form + .call(ApiStvGroups.add(this.modelValue.uid, gruppe_kurzbz, this.currentSemester)) + .then(result => { + this.$refs.newGroupModal.hide(); + this.$fhcAlert.alertSuccess(this.$p.t('gruppenmanagement/groups_added', { n: 1 })); + this.$refs.list.reload(); + }) + .catch(this.$fhcAlert.handleSystemError); + } + } + }, + template: /* html */` +
+

{{ $p.t('gruppenmanagement/special_groups') }}

+ + + +

{{ $p.t('lehre/lehrverband') }}

+ +
` +}; \ No newline at end of file diff --git a/public/js/components/Stv/Studentenverwaltung/Details/Groups/List.js b/public/js/components/Stv/Studentenverwaltung/Details/Groups/List.js new file mode 100644 index 000000000..bc4b5a86a --- /dev/null +++ b/public/js/components/Stv/Studentenverwaltung/Details/Groups/List.js @@ -0,0 +1,171 @@ +import { CoreFilterCmpt } from "../../../../filter/Filter.js"; + +import ApiStvGroups from '../../../../../api/factory/stv/group.js'; + +export default { + name: 'TabGroupsList', + components: { + CoreFilterCmpt + }, + inject: [ + "currentSemester" + ], + props: { + students: Object + }, + emits: [ + "new" + ], + data() { + return { + phrasenLoaded: false, + optionsReady: true + }; + }, + computed: { + tabulatorOptions() { + let ajaxRequestFunc, ajaxResponse, initialFilter; + if (Array.isArray(this.students)) { + ajaxRequestFunc = () => { + return this.$api.call( + this.students.map(student => ApiStvGroups.getGruppen(student.uid)) + ); + }; + ajaxResponse = (url, params, response) => { + return response.reduce((data, result) => [ + ...data, + ...result.value.data + ], []); + }; + initialFilter = [ + this.students.map(student => { + return { field: "uid", type: "=", value: student.uid }; + }), + [ + { field: "studiensemester_kurzbz", type: "=", value: null }, + { field: "studiensemester_kurzbz", type: "=", value: this.currentSemester } + ] + ]; + } else { + ajaxRequestFunc = () => { + return this.$api.call( + ApiStvGroups.getGruppen(this.students.uid) + ); + }; + ajaxResponse = (url, params, response) => { + return response.data; + }; + initialFilter = [ + { field: "uid", type: "=", value: this.students.uid }, + [ + { field: "studiensemester_kurzbz", type: "=", value: null }, + { field: "studiensemester_kurzbz", type: "=", value: this.currentSemester } + ] + ]; + } + return { + ajaxURL: 'dummy', + ajaxRequestFunc, + ajaxResponse, + initialFilter, + columns: [ + { title: this.$p.t('gruppenmanagement/gruppe'), field: "gruppe_kurzbz" }, + { title: this.$p.t('ui/bezeichnung'), field: "bezeichnung" }, + { title: this.$p.t('lehre/studiensemester'), field: "studiensemester_kurzbz" }, + { + title: this.$p.t('gruppenmanagement/automatisch_generiert'), + field: "generiert", + formatter: "tickCross", + hozAlign: "center", + formatterParams: { + tickElement: '', + crossElement: '' + } + }, + { title: this.$p.t('ui/student_uid'), field: "uid" }, + { + title: this.$p.t('global/actions'), + field: 'actions', + minWidth: 150, // Ensures Action-buttons will be always fully displayed + formatter: (cell, formatterParams, onRendered) => { + const container = document.createElement('div'); + container.className = "d-flex gap-2"; + + const data = cell.getData(); + + const button = document.createElement('button'); + button.className = 'btn btn-outline-secondary btn-action'; + button.innerHTML = ''; + button.title = this.$p.t('ui', 'loeschen'); + button.addEventListener('click', () => + this.actionDeleteGroup(data) + ); + if (data.generiert) + button.disabled = true; + container.append(button); + + return container; + }, + frozen: true + } + ], + height: 'auto', + index: 'group_id', + persistenceID: 'stv-details-group-list' + }; + } + }, + watch: { + tabulatorOptions() { + // Refresh Tabulator if options have changed + this.optionsReady = false; + this.$nextTick(() => this.optionsReady = true); + } + }, + methods: { + actionDeleteGroup(data) { + this.$fhcAlert + .confirmDelete() + .then(result => result + ? data + : Promise.reject({handled: true})) + .then(this.deleteGroup) + .catch(this.$fhcAlert.handleSystemError); + }, + deleteGroup(params) { + return this.$api + .call(ApiStvGroups.deleteGroup(params)) + .then(response => { + this.$fhcAlert.alertSuccess(this.$p.t('ui', 'successDelete')); + this.reload(); + }) + .catch(this.$fhcAlert.handleSystemError); + }, + reload() { + this.$refs.table.reloadTable(); + } + }, + mounted() { + this.$p + .loadCategory(['global', 'lehre', 'ui', 'gruppenmanagement']) + .then(() => { + this.phrasenLoaded = true; + }); + }, + template: /* html */` +
+ + +
` +}; \ No newline at end of file diff --git a/public/js/components/Stv/Studentenverwaltung/Details/Groups/Lvb.js b/public/js/components/Stv/Studentenverwaltung/Details/Groups/Lvb.js new file mode 100644 index 000000000..db94d359c --- /dev/null +++ b/public/js/components/Stv/Studentenverwaltung/Details/Groups/Lvb.js @@ -0,0 +1,242 @@ +import FhcForm from "../../../../Form/Form.js"; + +import ApiStvLvb from '../../../../../api/factory/stv/lehrverband.js'; + +export default { + name: 'TabGroupsLvb', + components: { + FhcForm + }, + props: { + students: Object + }, + emits: [ + "submit" + ], + data() { + return { + lvbList: [], + selectedSemester: false, + selectedVerband: false, + selectedGruppe: false + }; + }, + computed: { + allAreStudents() { + if (Array.isArray(this.students)) + return this.students.every(ps => ps.uid); + return this.students.uid; + }, + studiengang_kz() { + if (Array.isArray(this.students)) { + const first = this.students.find(Boolean); + if (this.students.every(ps => ps.studiengang_kz === first.studiengang_kz)) + return first.studiengang_kz; + return false; + } + return this.students.studiengang_kz; + }, + semester: { + get() { + if (this.selectedSemester !== false) { + if (this.lvbList.some(item => item.semester == this.selectedSemester)) + return this.selectedSemester; + return false; + } + if (Array.isArray(this.students)) { + const first = this.students.find(Boolean); + if (this.lvbList.every(item => item.semester != first.semester)) + return false; + if (this.students.every(ps => ps.semester === first.semester)) + return first.semester; + return false; + } + if (this.lvbList.some(item => item.semester == this.students.semester)) + return this.students.semester; + return false; + }, + set(value) { + this.selectedSemester = value; + } + }, + verband: { + get() { + if (this.semester === false) + return false; + if (this.selectedVerband !== false) { + if (this.lvbListVerband.some(item => item.verband == this.selectedVerband)) + return this.selectedVerband; + return false; + } + if (Array.isArray(this.students)) { + const first = this.students.find(Boolean); + if (this.lvbListVerband.every(item => item.verband != first.verband)) + return false; + if (this.students.every(ps => ps.verband === first.verband)) + return first.verband; + return false; + } + if (this.lvbListVerband.some(item => item.verband == this.students.verband)) + return this.students.verband; + return false; + }, + set(value) { + this.selectedVerband = value; + } + }, + gruppe: { + get() { + if (this.verband === false) + return false; + if (this.selectedGruppe !== false) { + if (this.lvbListGruppe.some(item => item.gruppe == this.selectedGruppe)) + return this.selectedGruppe; + return false; + } + if (Array.isArray(this.students)) { + const first = this.students.find(Boolean); + if (this.lvbListGruppe.every(item => item.gruppe != first.gruppe)) + return false; + if (this.students.every(ps => ps.gruppe === first.gruppe)) + return first.gruppe; + return false; + } + if (this.lvbListGruppe.some(item => item.gruppe == this.students.gruppe)) + return this.students.gruppe; + return false; + }, + set(value) { + this.selectedGruppe = value; + } + }, + stgSemester() { + if (!this.lvbList.length) + return []; + + const semester = new Set(this.lvbList.map(lvb => lvb.semester)); + + return Array.from(semester).sort((a, b) => a - b); + }, + lvbListVerband() { + if (!this.lvbList.length) + return []; + if (this.semester === false) + return []; + + return this.lvbList.filter(lvb => this.semester == lvb.semester); + }, + semesterVerband() { + if (!this.lvbListVerband.length) + return []; + + const verband = new Set(this.lvbListVerband.map(lvb => lvb.verband.replace(/ /g, ''))); + + return Array.from(verband).filter(Boolean).sort(); + }, + lvbListGruppe() { + if (!this.lvbListVerband.length) + return []; + if (this.verband === false) + return []; + + return this.lvbListVerband.filter(lvb => this.verband == lvb.verband); + }, + verbandGruppe() { + if (!this.lvbListGruppe.length) + return []; + + const gruppe = new Set(this.lvbListGruppe.map(lvb => lvb.gruppe.replace(/ /g, ''))); + + return Array.from(gruppe).filter(Boolean).sort(); + } + }, + watch: { + studiengang_kz() { + this.loadGroupsForStg(); + } + }, + methods: { + loadGroupsForStg() { + this.lvbList = []; + + if (this.studiengang_kz === false) + return; + + let lvbList; + this.$api + .call(ApiStvLvb.getTree(this.studiengang_kz)) + .then(result => this.lvbList = result.data) + .catch(this.$fhcAlert.handleSystemError) + }, + onSubmit() { + let params = { + studiengang_kz: this.studiengang_kz, + semester: this.semester, + verband: this.verband, + gruppe: this.gruppe + }; + this.$emit("submit", params); + } + }, + created() { + this.loadGroupsForStg(); + }, + template: /* html */` +
+ + + {{ $p.t('lehre/semester') }} + + + + {{ $p.t('lehre/verband') }} + + + + {{ $p.t('lehre/gruppe') }} + + + + +
+ {{ $p.t('stv/groups_error_notallstudents') }} +
+
+ {{ $p.t('stv/groups_error_notsamestg') }} +
+
` +}; \ No newline at end of file diff --git a/public/js/components/Stv/Studentenverwaltung/Details/Groups/Special.js b/public/js/components/Stv/Studentenverwaltung/Details/Groups/Special.js new file mode 100644 index 000000000..90b4c15ec --- /dev/null +++ b/public/js/components/Stv/Studentenverwaltung/Details/Groups/Special.js @@ -0,0 +1,97 @@ +import BsModal from "../../../../Bootstrap/Modal.js"; +import FhcForm from "../../../../Form/Form.js"; +import FormValidation from "../../../../Form/Validation.js"; +import FormInput from "../../../../Form/Input.js"; + +import ApiStvGroups from '../../../../../api/factory/stv/group.js'; + +export default { + name: 'TabGroupsSpecial', + components: { + BsModal, + FhcForm, + FormValidation, + FormInput, + PvAutocomplete: primevue.autocomplete + }, + props: { + defaultStg: Number + }, + emits: [ + "chosen" + ], + data() { + return { + value: '', + groupSuggestions: [] + }; + }, + methods: { + show() { + this.$refs.popup.show(); + }, + hide() { + this.$refs.popup.hide(); + }, + getGroupSuggestions({ query }) { + this.$api + .call(ApiStvGroups.search(query, this.defaultStg)) + .then(result => this.groupSuggestions = result.data) + .catch(this.$fhcAlert.handleSystemError); + }, + onSubmit(evt) { + if (!evt.defaultPrevented) { + evt.preventDefault(); + this.hide(); + } + }, + onEnter(evt) { + /** + * NOTE(chris): PrimeVue: AutoComplete: Enter does not submit form #5618 + * @see https://github.com/primefaces/primevue/issues/5618 + * + * this is fixed in 3.52.0 + * until then this function fill fix it + */ + if (!this.$refs.autocomplete.$refs.input.overlayVisible) { + this.$refs.form.$el.requestSubmit(); + } + }, + modalOpened() { + this.$refs.autocomplete.$refs.input.$refs.focusInput.focus(); + }, + modalClosed() { + this.value = ''; + this.$refs.form.clearValidation(); + } + }, + template: /* html */` + + + + +
+ + +
+
+
` +}; \ No newline at end of file diff --git a/public/js/components/Stv/Studentenverwaltung/Details/Gruppen/Gruppen.js b/public/js/components/Stv/Studentenverwaltung/Details/Gruppen/Gruppen.js deleted file mode 100644 index ab0c8d8e4..000000000 --- a/public/js/components/Stv/Studentenverwaltung/Details/Gruppen/Gruppen.js +++ /dev/null @@ -1,187 +0,0 @@ -import {CoreFilterCmpt} from "../../../../filter/Filter.js"; - -import ApiStvGroups from '../../../../../api/factory/stv/group.js'; - -export default { - name: 'TblGroups', - components: { - CoreFilterCmpt, - }, - inject: { - currentSemester: { - from: 'currentSemester', - }, - }, - props: { - student: Object - }, - data() { - return { - tabulatorOptions: { - ajaxURL: 'dummy', - ajaxRequestFunc: () => this.$api.call( - ApiStvGroups.getGruppen(this.student.uid) - ), - ajaxResponse: (url, params, response) => response.data, - initialFilter: { - logic: "and", - filters: [ - { field: "uid", operator: "eq", value: this.student.uid }, - { - logic: "or", - filters: [ - { field: "studiensemester_kurzbz", operator: "eq", value: null }, - { field: "studiensemester_kurzbz", operator: "eq", value: this.currentSemester } - ] - } - ] - }, - columns: [ - {title: "Gruppe", field: "gruppe_kurzbz"}, - {title: "Bezeichnung", field: "bezeichnung"}, - {title: "Semester", field: "studiensemester_kurzbz"}, - { - title: "automatisch generiert", - field: "generiert", - formatter: "tickCross", - hozAlign: "center", - formatterParams: { - tickElement: '', - crossElement: '' - } - }, - {title: "UID", field: "uid"}, - { - title: 'Aktionen', field: 'actions', - minWidth: 150, // Ensures Action-buttons will be always fully displayed - formatter: (cell, formatterParams, onRendered) => { - const container = document.createElement('div'); - container.className = "d-flex gap-2"; - - const data = cell.getData(); - - const button = document.createElement('button'); - button.className = 'btn btn-outline-secondary btn-action'; - button.innerHTML = ''; - button.title = this.$p.t('ui', 'loeschen'); - button.addEventListener('click', () => - this.actionDeleteGroup(data.gruppe_kurzbz) - ); - if (data.generiert) - button.disabled = true; - container.append(button); - - return container; - }, - frozen: true - }, - ], - layout: 'fitDataFill', - height: 'auto', - index: 'group_id', - persistenceID: 'stv-details-gruppe' - }, - tabulatorEvents: [ - { - event: 'tableBuilt', - handler: async () => { - - await this.$p.loadCategory(['global', 'person', 'stv', 'ui', 'gruppenmanagement']); - - let cm = this.$refs.table.tabulator.columnManager; - - cm.getColumnByField('gruppe_kurzbz').component.updateDefinition({ - title: this.$p.t('gruppenmanagement', 'gruppe') - }); - - cm.getColumnByField('bezeichnung').component.updateDefinition({ - title: this.$p.t('ui', 'bezeichnung') - }); - - cm.getColumnByField('generiert').component.updateDefinition({ - title: this.$p.t('gruppenmanagement', 'automatisch_generiert') - }); - - cm.getColumnByField('uid').component.updateDefinition({ - title: this.$p.t('ui', 'student_uid') - }); - - //Interference with Filter if not commented out - /* - cm.getColumnByField('studiensemester_kurzbz').component.updateDefinition({ - title: this.$p.t('lehre', 'studiensemester') - });*/ - - } - } - ], - } - }, - methods: { - actionDeleteGroup(gruppe_kurzbz) { - this.$fhcAlert - .confirmDelete() - .then(result => result - ? gruppe_kurzbz - : Promise.reject({handled: true})) - .then(this.deleteGroup) - .catch(this.$fhcAlert.handleSystemError); - - }, - deleteGroup(gruppe_kurzbz) { - const group_id = { - id: this.student.uid, - gruppe_kurzbz: gruppe_kurzbz - }; - - return this.$api - .call(ApiStvGroups.deleteGroup(group_id)) - .then(response => { - this.$fhcAlert.alertSuccess(this.$p.t('ui', 'successDelete')); - }).catch(this.$fhcAlert.handleSystemError) - .finally(() => { - window.scrollTo(0, 0); - this.reload(); - }); - }, - reload() { - this.$refs.table.reloadTable(); - }, - }, - watch: { - currentSemester(newVal) { - if (newVal) { - this.$refs.table.tabulator.clearFilter(); // Clear old filters - - this.$refs.table.tabulator.setFilter((data) => { - return ( - data.uid === this.student.uid && - ( - data.studiensemester_kurzbz === newVal || - data.studiensemester_kurzbz === null - ) - ); - }); - } - }, - student() { - this.$refs.table.reloadTable(); - } - }, - template: ` -
-
{{$p.t('stv', 'tab_groups')}}
- - - -
- ` -} \ No newline at end of file diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php index 82ea03ba0..61fa4ef04 100644 --- a/system/phrasesupdate.php +++ b/system/phrasesupdate.php @@ -1515,6 +1515,26 @@ $phrases = array( ) ) ), + array( + 'app' => 'core', + 'category' => 'ui', + 'phrase' => 'change', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Ändern', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Change', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), array( 'app' => 'core', 'category' => 'ui', @@ -25371,6 +25391,26 @@ array( ) ) ), + array( + 'app' => 'core', + 'category' => 'ui', + 'phrase' => 'error_fieldNotFound', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => '{field} nicht gefunden', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => '{field} not found', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), array( 'app' => 'core', 'category' => 'notiz', @@ -39164,6 +39204,47 @@ array( ) ) ), + // groups + array( + 'app' => 'core', + 'category' => 'stv', + 'phrase' => 'groups_error_notallstudents', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Fehler: Es können nur Studierende mit UID verschoben werden.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Error: Only students with UID can be moved.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'stv', + 'phrase' => 'groups_error_notsamestg', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Fehler: Alle Studierenden müssen im selben Studiengang sein.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Error: All students must be in the same program.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), //**************************** CORE/document_export array( @@ -43097,6 +43178,66 @@ array( ) ) ), + array( + 'app' => 'core', + 'category' => 'gruppenmanagement', + 'phrase' => 'special_groups', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Spezialgruppen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Special groups', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'gruppenmanagement', + 'phrase' => 'add_group', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Gruppe hinzufügen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Add group', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), + array( + 'app' => 'core', + 'category' => 'gruppenmanagement', + 'phrase' => 'groups_added', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => '{n} Gruppen hinzufügen', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Added {n} groups', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), array( 'app' => 'core', 'category' => 'gruppenmanagement', @@ -43137,6 +43278,26 @@ array( ) ) ), + array( + 'app' => 'core', + 'category' => 'gruppenmanagement', + 'phrase' => 'error_alreadyInGroup', + 'insertvon' => 'system', + 'phrases' => array( + array( + 'sprache' => 'German', + 'text' => 'Der Student {uid} ist bereits im {studiensemester_kurzbz} dieser Gruppe zugeteilt. Entfernen Sie vorher diese Zuteilung.', + 'description' => '', + 'insertvon' => 'system' + ), + array( + 'sprache' => 'English', + 'text' => 'Student {uid} is already assigned to this group in {studiensemester_kurzbz}. Remove this assignment beforehand.', + 'description' => '', + 'insertvon' => 'system' + ) + ) + ), //////////// FHC4 Phrases Gruppen End //////////// array( From 61cfb175e3247976fc671df56f43ee9c72332f8a Mon Sep 17 00:00:00 2001 From: chfhtw Date: Wed, 3 Sep 2025 11:06:09 +0200 Subject: [PATCH 04/10] use undefined to avoid VueJs warning --- public/js/components/Stv/Studentenverwaltung/Details/Groups.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/js/components/Stv/Studentenverwaltung/Details/Groups.js b/public/js/components/Stv/Studentenverwaltung/Details/Groups.js index 5b2c6fc73..fab9511fe 100644 --- a/public/js/components/Stv/Studentenverwaltung/Details/Groups.js +++ b/public/js/components/Stv/Studentenverwaltung/Details/Groups.js @@ -105,7 +105,7 @@ export default { const first = this.modelValue.find(Boolean); if (this.modelValue.every(ps => ps.studiengang_kz === first.studiengang_kz)) return first.studiengang_kz; - return false; + return undefined; } return this.modelValue.studiengang_kz; } From ff061a3e9591ad57b994ff750aefda6eee93a686 Mon Sep 17 00:00:00 2001 From: chfhtw Date: Fri, 5 Sep 2025 14:04:41 +0200 Subject: [PATCH 05/10] FhcAPI: expose getConfig function --- public/js/plugins/Api.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/public/js/plugins/Api.js b/public/js/plugins/Api.js index 0b8e2fd17..6b9627698 100644 --- a/public/js/plugins/Api.js +++ b/public/js/plugins/Api.js @@ -546,6 +546,9 @@ export default { } else { console.error("FhcApi: method not allowed:", method); } + }, + getErrorHandler(config) { + return get_error_handler(config); } }; From 455698b28e87366a365be0c33b22af53dfefa7f4 Mon Sep 17 00:00:00 2001 From: chfhtw Date: Wed, 10 Sep 2025 11:18:23 +0200 Subject: [PATCH 06/10] StV Groups Drag&Drop --- .../api/frontend/v1/stv/Student.php | 26 ++ .../api/frontend/v1/stv/Verband.php | 3 + public/js/api/factory/stv/details.js | 10 + .../components/Calendar/Base/DragAndDrop.js | 7 +- .../Stv/Studentenverwaltung/List.js | 83 ++++- .../Stv/Studentenverwaltung/Verband.js | 78 +++- public/js/directives/dragClick.js | 101 ++++++ public/js/directives/draggable.js | 97 +++++ public/js/directives/drop.js | 57 +++ public/js/helpers/DragAndDrop.js | 342 +++++++++++++++--- public/js/sharedworkers/dragend.js | 30 ++ 11 files changed, 758 insertions(+), 76 deletions(-) create mode 100644 public/js/directives/dragClick.js create mode 100644 public/js/directives/draggable.js create mode 100644 public/js/directives/drop.js create mode 100644 public/js/sharedworkers/dragend.js diff --git a/application/controllers/api/frontend/v1/stv/Student.php b/application/controllers/api/frontend/v1/stv/Student.php index 0cfd82c36..5cff2b173 100644 --- a/application/controllers/api/frontend/v1/stv/Student.php +++ b/application/controllers/api/frontend/v1/stv/Student.php @@ -36,6 +36,7 @@ class Student extends FHCAPI_Controller parent::__construct([ 'get' => ['admin:r', 'assistenz:r'], 'save' => ['admin:rw', 'assistenz:rw'], + 'saveStudent' => ['admin:rw', 'assistenz:rw'], 'check' => ['admin:rw', 'assistenz:rw'], 'add' => ['admin:rw', 'assistenz:rw'] // TODO(chris): extra permissions ]); @@ -414,6 +415,31 @@ class Student extends FHCAPI_Controller ), '')); } + /** + * Saves data to a prestudent using their student_uid + * + * @param string $student_uid + * @param string $studiensemester_kurzbz + * @return void + */ + public function saveStudent($student_uid, $studiensemester_kurzbz) + { + $this->load->model('crm/Student_model', 'StudentModel'); + + $result = $this->StudentModel->load([$student_uid]); + + $data = $this->getDataOrTerminateWithError($result); + + if (!$data) + show_404(); // No Student with that ID + + $student = current($data); + + $this->checkPermissionsForPrestudent($student->prestudent_id, ['admin:rw', 'assistenz:rw']); + + return $this->save($student->prestudent_id, $studiensemester_kurzbz); + } + public function check() { $this->load->library('form_validation'); diff --git a/application/controllers/api/frontend/v1/stv/Verband.php b/application/controllers/api/frontend/v1/stv/Verband.php index 4060704de..4ff2c74fe 100644 --- a/application/controllers/api/frontend/v1/stv/Verband.php +++ b/application/controllers/api/frontend/v1/stv/Verband.php @@ -271,6 +271,7 @@ class Verband extends FHCAPI_Controller $this->StudiengangModel->addSelect("CONCAT(UPPER(CONCAT(typ, kurzbz)), '-', semester, verband, (SELECT CASE WHEN bezeichnung IS NULL OR bezeichnung='' THEN ''::TEXT ELSE CONCAT(' (', bezeichnung, ')') END FROM public.tbl_lehrverband WHERE studiengang_kz=v.studiengang_kz AND semester=v.semester AND verband=v.verband ORDER BY gruppe LIMIT 1)) AS name", false); $this->StudiengangModel->addSelect("CASE WHEN MAX(gruppe)='' OR MAX(gruppe)=' ' THEN TRUE ELSE FALSE END AS leaf"); + $this->StudiengangModel->addSelect($this->StudiengangModel->escape($semester) . ' AS semester'); $this->StudiengangModel->addSelect('verband'); $this->StudiengangModel->addSelect($this->StudiengangModel->escape($studiengang_kz) . '::integer AS stg_kz', false); @@ -319,6 +320,8 @@ class Verband extends FHCAPI_Controller $this->StudiengangModel->addSelect("CONCAT(UPPER(CONCAT(typ, kurzbz)), '-', semester, verband, gruppe, (SELECT CASE WHEN bezeichnung IS NULL OR bezeichnung='' THEN ''::TEXT ELSE CONCAT(' (', bezeichnung, ')') END FROM public.tbl_lehrverband WHERE studiengang_kz=v.studiengang_kz AND semester=v.semester AND verband=v.verband AND gruppe=v.gruppe ORDER BY gruppe LIMIT 1)) AS name", false); $this->StudiengangModel->addSelect("TRUE AS leaf", false); + $this->StudiengangModel->addSelect('v.semester'); + $this->StudiengangModel->addSelect('v.verband'); $this->StudiengangModel->addSelect('gruppe'); $this->StudiengangModel->addSelect($this->StudiengangModel->escape($studiengang_kz) . '::integer AS stg_kz', false); diff --git a/public/js/api/factory/stv/details.js b/public/js/api/factory/stv/details.js index 1077de22d..577bc14fb 100644 --- a/public/js/api/factory/stv/details.js +++ b/public/js/api/factory/stv/details.js @@ -35,4 +35,14 @@ export default { params }; }, + saveStudent(student_uid, studiensemester_kurzbz, params) { + return { + method: 'post', + url: 'api/frontend/v1/stv/student/saveStudent/' + + encodeURIComponent(student_uid) + + '/' + + encodeURIComponent(studiensemester_kurzbz), + params + }; + } }; \ No newline at end of file diff --git a/public/js/components/Calendar/Base/DragAndDrop.js b/public/js/components/Calendar/Base/DragAndDrop.js index 0be17ddb4..631a792a8 100644 --- a/public/js/components/Calendar/Base/DragAndDrop.js +++ b/public/js/components/Calendar/Base/DragAndDrop.js @@ -96,8 +96,11 @@ export default { }, methods: { onDragstart(evt) { - DragAndDrop.setTransferData(evt.detail.originalEvent, evt.detail.item.orig); - this.draggedInternalEvent = evt.detail.item; + const data = DragAndDrop.convertToTransferData(evt.detail.item.orig); + if (DragAndDrop.isValidDragObject(data)) { + DragAndDrop.setTransferData(evt.detail.originalEvent, data); + this.draggedInternalEvent = evt.detail.item; + } }, onDragend() { this.draggedInternalEvent = null; diff --git a/public/js/components/Stv/Studentenverwaltung/List.js b/public/js/components/Stv/Studentenverwaltung/List.js index 67cada523..9e4cd4296 100644 --- a/public/js/components/Stv/Studentenverwaltung/List.js +++ b/public/js/components/Stv/Studentenverwaltung/List.js @@ -1,6 +1,7 @@ import {CoreFilterCmpt} from "../../filter/Filter.js"; import ListNew from './List/New.js'; +import draggable from '../../../directives/draggable.js'; export default { name: "ListPrestudents", @@ -8,11 +9,18 @@ export default { CoreFilterCmpt, ListNew }, + directives: { + draggable + }, inject: { - 'lists': { + lists: { from: 'lists', required: true }, + $reloadList: { + from: '$reloadList', + required: true + }, currentSemester: { from: 'currentSemester', required: true @@ -165,6 +173,39 @@ export default { currentEndpointRawUrl: '' } }, + computed: { + countsToHTML: function() { + return this.$p.t('global/ausgewaehlt') + + ': ' + (this.selectedcount || 0) + '' + + ' | ' + + this.$p.t('global/gefiltert') + + ': ' + + '' + (this.filteredcount || 0) + '' + + ' | ' + + this.$p.t('global/gesamt') + + ': ' + (this.count || 0) + ''; + }, + selectedDragObject() { + return this.selected.map(item => { + let type, id; + if (item.uid) { + type = 'student'; + id = item.uid; + } else if (item.prestudent_id) { + type = 'prestudent'; + id = item.prestudent_id; + } else if (item.person_id) { + type = 'person'; + id = item.person_id; + } + return { + ...item, + type, + id + }; + }); + } + }, methods: { reload() { this.$refs.table.reloadTable(); @@ -172,10 +213,19 @@ export default { actionNewPrestudent() { this.$refs.new.open(); }, - rowSelectionChanged(data) { + rowSelectionChanged(data, rows) { this.selectedcount = data.length; this.lastSelected = this.selected; this.$emit('update:selected', data); + + // set selected elements draggable + const tableEl = this.$refs.table?.$refs?.table; + if (tableEl) { + const oldDragables = tableEl.querySelectorAll('[draggable]'); + for (const draggable of oldDragables) + draggable.removeAttribute('draggable'); + } + rows.forEach(row => row.getElement().draggable = true); }, autoSelectRows(data) { if (this.lastSelected) { @@ -294,26 +344,27 @@ export default { if (el != this.focusObj) this.changeFocus(this.focusObj, el); } - } - }, - computed: { - countsToHTML: function() { - return this.$p.t('global/ausgewaehlt') - + ': ' + (this.selectedcount || 0) + '' - + ' | ' - + this.$p.t('global/gefiltert') - + ': ' - + '' + (this.filteredcount || 0) + '' - + ' | ' - + this.$p.t('global/gesamt') - + ': ' + (this.count || 0) + ''; + }, + dragCleanup(evt) { + if (evt.dataTransfer.dropEffect == 'none') + return; // aborted or wrong target + + this.$reloadList(); } }, // TODO(chris): focusin, focusout, keydown and tabindex should be in the filter component // TODO(chris): filter component column chooser has no accessibilty features template: `
-
+
[ + this.getStudentAjaxId(student), + ApiStvGroups.add( + student.id, + data.gruppe_kurzbz, + this.currentSemester + ) + ]); + } else { + const { semester, verband, gruppe } = data; + const params = { semester, verband, gruppe }; + endpoint = students.map(student => [ + this.getStudentAjaxId(student), + ApiStvDetails.saveStudent( + student.id, + this.currentSemester, + params + ) + ]); + } + + return this.$api + .call(endpoint) + .then(this.$reloadList) + .catch(this.$fhcAlert.handleSystemError); } }, mounted() { @@ -268,10 +331,21 @@ export default { diff --git a/public/js/directives/dragClick.js b/public/js/directives/dragClick.js new file mode 100644 index 000000000..aeeafb818 --- /dev/null +++ b/public/js/directives/dragClick.js @@ -0,0 +1,101 @@ +export default { + mounted(el, binding) { + const delay = parseInt(binding.arg) || 300; + + let timeout = null; + function startCountdown() { + timeout = window.setTimeout(binding.value, delay); + } + function stopCountdown() { + if (timeout) + window.clearTimeout(timeout); + timeout = null; + } + + function onEnter(evt) { + let lastTarget = evt.target; + let lastX = evt.offsetX; + let lastY = evt.offsetY; + + el.addEventListener('dragover', evt => { + if (lastX != evt.offsetX || lastY != evt.offsetY || lastTarget != evt.target) { + // moved + lastTarget = evt.target; + lastX = evt.offsetX; + lastY = evt.offsetY; + + stopCountdown(); + startCountdown(); + } + }); + + startCountdown(); + } + function onLeave() { + stopCountdown(); + } + + // NOTE(chris): add save dragenter and dragleave events + // that won't fire when hovering over child elements + + let skipLeave = false; + let skipLeaveParent = true; + + function init(evt) { + skipLeave = false; + skipLeaveParent = true; + // add global listeners + window.addEventListener('dragenter', globalDragenter, true); + window.addEventListener('dragleave', globalDragleave, true); + window.addEventListener('drop', globalDrop, true); + // call enter + onEnter(evt); + // remove self + el.removeEventListener('dragenter', init); + } + + function cleanup() { + // remove global listeners + window.removeEventListener('dragenter', globalDragenter, true); + window.removeEventListener('dragleave', globalDragleave, true); + window.removeEventListener('drop', globalDrop, true); + // call leave + onLeave(); + // add init + el.addEventListener('dragenter', init); + } + + function globalDragenter(evt) { + skipLeaveParent = false; + if (el != evt.target && !el.contains(evt.target)) { + cleanup(); + } else { + skipLeave = true; + } + } + function globalDragleave(evt) { + if (el != evt.target && !el.contains(evt.target)) { + if (skipLeaveParent) { + skipLeaveParent = false; + return; + } + } else { + if (skipLeave) { + skipLeave = false; + return; + } + } + cleanup(); + } + function globalDrop(evt) { + cleanup(); + } + + el.addEventListener('dragenter', init); + el.initFunc = init; + }, + beforeUnmount(el) { + el.removeEventListener('dragenter', el.initFunc); + delete el.initFunc; + } +} diff --git a/public/js/directives/draggable.js b/public/js/directives/draggable.js new file mode 100644 index 000000000..c72ad49f2 --- /dev/null +++ b/public/js/directives/draggable.js @@ -0,0 +1,97 @@ +import { setTransferData, convertToValidDragObject, dragendWorker } from '../helpers/DragAndDrop.js'; + +const EFFECTS = [ + 'none', + 'copy', + 'copyLink', + 'copyMove', + 'link', + 'linkMove', + 'move', + 'all', + 'uninitialized' +]; + +export default { + mounted(el, binding, vnode) { + updateValue(el, binding.value); + updateEffectAllowed(el, binding.arg); + + // if modifier capture is set we assume it's on a parent element + // i.e: for dragging multiple elements + // otherwise set draggable attribute + if (!binding.modifiers.capture) { + el.draggable = true; + } + + el.addEventListener('dragstart', evt => { + const value = el.dataset.fhcDraggableValue; + if (value) { + setTransferData(evt, JSON.parse(value), true); + if (el.dataset.fhcEffectAllowed) + evt.dataTransfer.effectAllowed = el.dataset.fhcEffectAllowed; + blockDragend(); + } else { + evt.preventDefault(); + } + }, binding.modifiers.capture); + + let id; + let evt = null; + let dataTransfer = null; + function blockDragend() { + id = el.dataset.fhcDraggableValue; + dragendWorker.port.postMessage(['init', id]); + window.addEventListener('dragend', blockHandler, true); + } + function unblockDragend(e) { + if (e) { + evt = e; + dataTransfer = e.dataTransfer; + } + window.removeEventListener('dragend', blockHandler, true); + } + + function blockHandler(evt) { + if (evt.dataTransfer.dropEffect == 'none') + return unblockDragend(); + unblockDragend(evt); + evt.stopPropagation(); + dragendWorker.port.postMessage(['request']); + } + + dragendWorker.port.onmessage = e => { + const [ func, ...args ] = e.data; + if (func != 'fire') + return; + const [ targetId ] = args; + if (targetId != id) + return; + if (evt === null) + unblockDragend(); + else + el.dispatchEvent(evt); + } + }, + updated(el, binding) { + updateValue(el, binding.value); + updateEffectAllowed(el, binding.arg); + } +} + +// Helper functions +function updateValue(el, value) { + value = convertToValidDragObject(value); + if (value) { + el.dataset.fhcDraggableValue = JSON.stringify(value); + } else if (el.dataset.fhcDraggableValue) { + delete el.dataset.fhcDraggableValue; + } +} +function updateEffectAllowed(el, effectAllowed) { + if (effectAllowed && EFFECTS.includes(effectAllowed)) { + el.dataset.fhcEffectAllowed = effectAllowed; + } else if (el.dataset.fhcEffectAllowed) { + delete el.dataset.fhcEffectAllowed; + } +} diff --git a/public/js/directives/drop.js b/public/js/directives/drop.js new file mode 100644 index 000000000..12253386c --- /dev/null +++ b/public/js/directives/drop.js @@ -0,0 +1,57 @@ +import { getValidTransferData, eventHasTypes, dragendWorker } from '../helpers/DragAndDrop.js'; + +const EFFECTS = [ + 'move', + 'copy', + 'link', + 'none' +]; + +let id = 0; + +export default { + mounted(el, binding, vnode) { + const allowedTypes = Object.keys(binding.modifiers); + allowedTypes.forEach(type => { + if (type.substr(-11) == '-collection') { + const singleType = type.substr(0, type.length-11); + if (!allowedTypes.includes(singleType)) + allowedTypes.push(singleType); + } + }); + + const strict = binding.arg.match(/(strict-|-strict)/); + const arg = binding.arg.replace(/(strict-|-strict)/, ''); + const effect = EFFECTS.includes(arg) ? arg : null; + + let allowed = false; + + el.addEventListener('dragenter', evt => { + allowed = eventHasTypes(evt, allowedTypes, strict); + if (allowed) + evt.preventDefault(); + }); + el.addEventListener('dragover', evt => { + if (allowed) { + evt.preventDefault(); + if (effect) + evt.dataTransfer.dropEffect = effect; + } + }); + el.addEventListener('drop', evt => { + let result = getValidTransferData(evt, allowedTypes, strict); + if (!Array.isArray(result) && !binding.modifiers[result.type] && allowedTypes.includes(result.type + '-collection')) + result = [result]; + + const res = binding.value(evt, result); + if (res instanceof Promise) { + const localId = id++; + dragendWorker.port.postMessage(['block', localId]); + res.then(r => { + dragendWorker.port.postMessage(['unblock', localId]); + return r; + }); + } + }); + } +} diff --git a/public/js/helpers/DragAndDrop.js b/public/js/helpers/DragAndDrop.js index 1160400f7..be068300c 100644 --- a/public/js/helpers/DragAndDrop.js +++ b/public/js/helpers/DragAndDrop.js @@ -1,67 +1,297 @@ /** * TODO(chris): This is only a prototype!!! */ -const DragAndDrop = { - TYPE_LE: "lehreinheit", - TYPE_VEVENT: "vevent", - getValidTransferData(event, allowedTypes) { - const json = event.dataTransfer.getData('text'); - let obj; - try { - obj = JSON.parse(json); - if (!obj.type) - return null; - if (allowedTypes && !allowedTypes.includes(obj.type)) - return null; - } catch (error) { - return null; - } - return obj; +const dragendWorker = new SharedWorker(new URL("../sharedworkers/dragend.js", import.meta.url)); + +const TYPE_DEFINITION = { + lehreinheit: { + id: "lehreinheit_id", + dragIcon: "fa-solid fa-chalkboard-user", + extras: [ + "stundenblockung" + ] }, - isValidTransferData(event, allowedTypes) { - return this.getValidTransferData(event, allowedTypes) ? true : false; + vevent: { + id: "uid", + dragIcon: "fa-solid fa-calendar", + extras: [ + "dtstart", + "dtend", + "summary" + ] }, - getTransferData(event) { - const json = event.dataTransfer.getData('text'); - return JSON.parse(json); + person: { + id: "person_id", + dragIcon: "fa-solid fa-user" }, - setTransferData(event, data) { - switch (data.type) { - case DragAndDrop.TYPE_LE: - data = DragAndDrop.fromLe(data); - break; - default: - if (data.dtstart && data.dtend && data.uid && data.summary) { - data = DragAndDrop.fromVEvent(data); - break; - } - return false; // No type found => abort - } - - event.dataTransfer.setData('text', JSON.stringify(data)); - return true; + student: { + id: "student_uid", + dragIcon: "fa-solid fa-user-graduate" }, - fromLe(data) { - const { - type = DragAndDrop.TYPE_LE, - lehreinheit_id: id, - stundenblockung - } = data; - - return { type, id, stundenblockung }; - }, - fromVEvent(data) { - const { - type = DragAndDrop.TYPE_VEVENT, - uid: id, - dtstart, - dtend, - summary - } = data; - - return { type, id, dtstart, dtend, summary }; + prestudent: { + id: "prestudent_id", + dragIcon: "fa-solid fa-user-graduate text-muted" } + // TODO: IMPLEMENT OTHER TYPES }; -export default DragAndDrop; +const VALID_TYPES = Object.keys(TYPE_DEFINITION); + +const TYPE_CONSTANTS = Object.keys(TYPE_DEFINITION).reduce((res, type) => { + res['TYPE_' + type.toUpperCase()] = type; + return res; +}, {}); + +function isValidDragObject(value) { + if (!value) + return false; + if (Array.isArray(value)) + return value.every(isValidDragObject); + if (!value.type) + return false; + + if (value.type.substr(-11) == '-collection') { + if (!value.hasOwnProperty('values')) + return false; + + if (!VALID_TYPES.includes(value.type.substr(0, value.type.length-11))) + return false; + } else { + if (!value.hasOwnProperty('id')) + return false; + + if (!VALID_TYPES.includes(value.type)) + return false; + + if (TYPE_DEFINITION[value.type].extras) { + if (!TYPE_DEFINITION[value.type].extras.every(extra => value.hasOwnProperty(extra))) + return false; + } + } + + return true; +} + +function getValidTransferData(event, allowedTypes, strict) { + let obj = null; + + try { + obj = getTransferData(event, strict); + if (!obj) + return null; + + if (!strict && Array.isArray(obj)) { + obj = obj.filter(isValidDragObject); + if (!obj.length) + return null; + } else if (!isValidDragObject(obj)) + return null; + + if (allowedTypes && allowedTypes.length) { + if (Array.isArray(obj)) { + if (strict && !obj.every(v => allowedTypes.includes(v.type))) { + return null; + } else if (!strict) { + obj = obj.filter(v => allowedTypes.includes(v.type)); + if (!obj.length) + return null; + } + } else if (!allowedTypes.includes(obj.type)) { + return null; + } + } + } catch(error) { + return null; + } + + if (Array.isArray(obj) && obj.length == 1) + return obj.find(Boolean); + + return obj; +} + +function isValidTransferData(event, allowedTypes, strict) { + return getValidTransferData(event, allowedTypes, strict) ? true : false; +} + +function getTransferData(event, strict) { + const result = []; + + for (const type of event.dataTransfer.types) { + if (type.substr(0, 4) != 'fhc/') { + if (strict) + return null; + continue; + } + let base_type = type.substr(4); + let collection = false; + if (base_type.substr(-11) == '-collection') { + base_type = base_type.substr(0, base_type.length-11); + collection = true; + } + if (!VALID_TYPES.includes(base_type)) { + if (strict) + return null; + continue; + } + let data = JSON.parse(event.dataTransfer.getData(type)); + if (collection) + result.push(...data.values); + else + result.push(data); + } + + if (!result.length) + return null; + + if (result.length == 1) + return result[0]; + + return result; +} + +function convertToValidDragObject(data, strict) { + if (Array.isArray(data)) { + const converted = data.map(convertToValidDragObject).filter(Boolean); + if (!converted.length) + return undefined; + if (strict && converted.length != data.length) + return undefined; + + const sorted = converted.reduce((res, item) => { + if (!res[item.type]) + res[item.type] = []; + res[item.type].push(item); + return res; + }, {}); + + return Object.entries(sorted).map(([type, values]) => { + if (values.length > 1) { + return { + type: type + '-collection', + values + }; + } + return values[0]; + }); + } + + if (data.hasOwnProperty('type') && isValidDragObject(data)) { + return data; + } + + const found = Object.entries(TYPE_DEFINITION).find(([type, typedef]) => { + if (!data.hasOwnProperty(typedef.id)) + return false; + if (typedef.extras) { + if (!typedef.extras.every(extra => data.hasOwnProperty("extra"))) + return false; + } + return true; + }); + + if (!found) { + return undefined; + } + + const [ type, typedef ] = found; + + const newData = {}; + newData.type = type; + newData.id = data[typedef.id]; + if (typedef.extras) + typedef.extras.forEach(extra => newData[extras] = data[extra]); + + return newData; +} + +function setTransferData(event, validDragObject, setDragImage = false) { + if (setDragImage) { + const dragItems = Array.isArray(validDragObject) ? validDragObject : [ validDragObject ]; + const dragElements = dragItems.map(item => { + const icon = document.createElement('i'); + const label = document.createElement('span'); + const iconContainer = document.createElement('span'); + + iconContainer.className = 'btn btn-outline-dark bg-light'; + label.className = 'small'; + + if (TYPE_DEFINITION[item.type]) { + icon.className = TYPE_DEFINITION[item.type].dragIcon || 'fa-solid fa-question'; + label.textContent = item.id; + } else if (item.type.substr(-11) == '-collection' && TYPE_DEFINITION[item.type.substr(0, item.type.length-11)]) { + iconContainer.style.boxShadow = '3px 3px var(--bs-btn-border-color)'; + icon.className = TYPE_DEFINITION[item.type.substr(0, item.type.length-11)].dragIcon || 'fa-solid fa-question'; + label.textContent = 'x' + item.values.length; + } else { + icon.className = 'fa-solid fa-question'; + label.textContent = item.id || ''; + } + + iconContainer.append(icon); + + const itemContainer = document.createElement('div'); + itemContainer.className = 'd-flex flex-column align-items-center gap-2 small'; + itemContainer.append(iconContainer, label); + return itemContainer; + }); + + const container = document.createElement('div'); + container.className = 'd-flex flex-row gap-2 small'; + container.append(...dragElements); + + document.body.append(container); + event.dataTransfer.setDragImage(container, -25, 0); + requestAnimationFrame(() => { + document.body.removeChild(container); + }); + } + if (Array.isArray(validDragObject)) { + return validDragObject.forEach(data => setTransferData(event, data)); + } + + event.dataTransfer.setData('fhc/' + validDragObject.type, JSON.stringify(validDragObject)); +} + +/** + * check if the dataTransfer types are in the allowed types array + * if strict is disabled at least one type must be the allowed array + * otherwise all types have to be in the allowed array + * + * @param Event event + * @param Array allowedTypes + * @param Boolean strict + */ +function eventHasTypes(event, allowedTypes, strict) { + if (!allowedTypes || !allowedTypes.length) + allowedTypes = VALID_TYPES; + allowedTypes = allowedTypes.map(type => 'fhc/' + type); + + if (!strict) + return allowedTypes.some(type => event.dataTransfer.types.includes(type)); + + return event.dataTransfer.types.every(type => allowedTypes.includes(type)); +} + +export { + isValidDragObject, + getValidTransferData, + isValidTransferData, + getTransferData, + convertToValidDragObject, + setTransferData, + eventHasTypes, + dragendWorker +}; +export default { + ...TYPE_CONSTANTS, + isValidDragObject, + getValidTransferData, + isValidTransferData, + getTransferData, + convertToValidDragObject, + setTransferData, + eventHasTypes, + dragendWorker +}; diff --git a/public/js/sharedworkers/dragend.js b/public/js/sharedworkers/dragend.js new file mode 100644 index 000000000..64179afa6 --- /dev/null +++ b/public/js/sharedworkers/dragend.js @@ -0,0 +1,30 @@ +let dragEndCallback = null; + +onconnect = e => { + const port = e.ports[0]; + + const cbList = []; + + port.onmessage = e => { + const [ func, ...args ] = e.data; + switch (func) { + case 'init': + dragEndCallback = () => { + port.postMessage(['fire', args]); + }; + break; + case 'block': + cbList[args[0]] = dragEndCallback; + dragEndCallback = null; + break; + case 'unblock': + cbList[args[0]](); + break; + case 'request': + if (dragEndCallback) { + dragEndCallback(); + dragEndCallback = null; + } + } + }; +}; From 86bbfe42dbc60d5eb312f6dafa03c4d60d1cb470 Mon Sep 17 00:00:00 2001 From: chfhtw Date: Wed, 10 Sep 2025 11:21:58 +0200 Subject: [PATCH 07/10] missing break --- public/js/sharedworkers/dragend.js | 1 + 1 file changed, 1 insertion(+) diff --git a/public/js/sharedworkers/dragend.js b/public/js/sharedworkers/dragend.js index 64179afa6..0b80e972a 100644 --- a/public/js/sharedworkers/dragend.js +++ b/public/js/sharedworkers/dragend.js @@ -25,6 +25,7 @@ onconnect = e => { dragEndCallback(); dragEndCallback = null; } + break; } }; }; From 112211fb0bdfa455c3a53f92fd025a74406cf02c Mon Sep 17 00:00:00 2001 From: chfhtw Date: Thu, 11 Sep 2025 13:11:34 +0200 Subject: [PATCH 08/10] move function --- public/js/components/Stv/Studentenverwaltung/List.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/public/js/components/Stv/Studentenverwaltung/List.js b/public/js/components/Stv/Studentenverwaltung/List.js index 9e4cd4296..7c6f57f48 100644 --- a/public/js/components/Stv/Studentenverwaltung/List.js +++ b/public/js/components/Stv/Studentenverwaltung/List.js @@ -284,6 +284,12 @@ export default { } else this.$refs.table.tabulator.setData(endpoint.url, params); }, + dragCleanup(evt) { + if (evt.dataTransfer.dropEffect == 'none') + return; // aborted or wrong target + + this.$reloadList(); + }, onKeydown(e) { // TODO(chris): this should be in the filter component if (!this.focusObj) return; @@ -344,12 +350,6 @@ export default { if (el != this.focusObj) this.changeFocus(this.focusObj, el); } - }, - dragCleanup(evt) { - if (evt.dataTransfer.dropEffect == 'none') - return; // aborted or wrong target - - this.$reloadList(); } }, // TODO(chris): focusin, focusout, keydown and tabindex should be in the filter component From ee6e7f3a48ac30f538a29a91f3528662e019db83 Mon Sep 17 00:00:00 2001 From: chfhtw Date: Thu, 11 Sep 2025 13:13:57 +0200 Subject: [PATCH 09/10] finetuning drag&drop --- public/js/directives/dragClick.js | 100 ++++++++--------------------- public/js/directives/draggable.js | 76 +++++++++++----------- public/js/directives/drop.js | 44 +++++++++---- public/js/helpers/DragAndDrop.js | 91 +++++++++++++++++++++++--- public/js/sharedworkers/dragend.js | 31 --------- 5 files changed, 180 insertions(+), 162 deletions(-) delete mode 100644 public/js/sharedworkers/dragend.js diff --git a/public/js/directives/dragClick.js b/public/js/directives/dragClick.js index aeeafb818..5603eb93f 100644 --- a/public/js/directives/dragClick.js +++ b/public/js/directives/dragClick.js @@ -1,3 +1,5 @@ +import { bindDragEnterLeave } from '../helpers/DragAndDrop.js'; + export default { mounted(el, binding) { const delay = parseInt(binding.arg) || 300; @@ -12,90 +14,40 @@ export default { timeout = null; } + let lastTarget; + let lastX; + let lastY; + + function onDragOver(evt) { + if (lastX != evt.offsetX || lastY != evt.offsetY || lastTarget != evt.target) { + // moved + lastTarget = evt.target; + lastX = evt.offsetX; + lastY = evt.offsetY; + + stopCountdown(); + startCountdown(); + } + } + function onEnter(evt) { - let lastTarget = evt.target; - let lastX = evt.offsetX; - let lastY = evt.offsetY; + lastTarget = evt.target; + lastX = evt.offsetX; + lastY = evt.offsetY; - el.addEventListener('dragover', evt => { - if (lastX != evt.offsetX || lastY != evt.offsetY || lastTarget != evt.target) { - // moved - lastTarget = evt.target; - lastX = evt.offsetX; - lastY = evt.offsetY; - - stopCountdown(); - startCountdown(); - } - }); + el.addEventListener('dragover', onDragOver); startCountdown(); } function onLeave() { stopCountdown(); + el.removeEventListener('dragover', onDragOver); } - // NOTE(chris): add save dragenter and dragleave events - // that won't fire when hovering over child elements - - let skipLeave = false; - let skipLeaveParent = true; - - function init(evt) { - skipLeave = false; - skipLeaveParent = true; - // add global listeners - window.addEventListener('dragenter', globalDragenter, true); - window.addEventListener('dragleave', globalDragleave, true); - window.addEventListener('drop', globalDrop, true); - // call enter - onEnter(evt); - // remove self - el.removeEventListener('dragenter', init); - } - - function cleanup() { - // remove global listeners - window.removeEventListener('dragenter', globalDragenter, true); - window.removeEventListener('dragleave', globalDragleave, true); - window.removeEventListener('drop', globalDrop, true); - // call leave - onLeave(); - // add init - el.addEventListener('dragenter', init); - } - - function globalDragenter(evt) { - skipLeaveParent = false; - if (el != evt.target && !el.contains(evt.target)) { - cleanup(); - } else { - skipLeave = true; - } - } - function globalDragleave(evt) { - if (el != evt.target && !el.contains(evt.target)) { - if (skipLeaveParent) { - skipLeaveParent = false; - return; - } - } else { - if (skipLeave) { - skipLeave = false; - return; - } - } - cleanup(); - } - function globalDrop(evt) { - cleanup(); - } - - el.addEventListener('dragenter', init); - el.initFunc = init; + el.fhcDragClickCleanup = bindDragEnterLeave(el, onEnter, onLeave); }, beforeUnmount(el) { - el.removeEventListener('dragenter', el.initFunc); - delete el.initFunc; + el.fhcDragClickCleanup(); + delete el.fhcDragClickCleanup; } } diff --git a/public/js/directives/draggable.js b/public/js/directives/draggable.js index c72ad49f2..052fcb542 100644 --- a/public/js/directives/draggable.js +++ b/public/js/directives/draggable.js @@ -1,4 +1,4 @@ -import { setTransferData, convertToValidDragObject, dragendWorker } from '../helpers/DragAndDrop.js'; +import { setTransferData, convertToValidDragObject } from '../helpers/DragAndDrop.js'; const EFFECTS = [ 'none', @@ -24,58 +24,62 @@ export default { el.draggable = true; } - el.addEventListener('dragstart', evt => { + const bcc = new BroadcastChannel('fhc-dnd'); + let blocked = false; + + function onStart(evt) { const value = el.dataset.fhcDraggableValue; if (value) { setTransferData(evt, JSON.parse(value), true); if (el.dataset.fhcEffectAllowed) evt.dataTransfer.effectAllowed = el.dataset.fhcEffectAllowed; - blockDragend(); + + bcc.onmessage = e => { + if (e.data == 'block') { + blocked = true; + } else if (e.data == 'release') { + let evt = null; + if (blocked && blocked.evt) { + evt = blocked.evt; + } + blocked = false; + if (evt) + el.dispatchEvent(evt); + } + }; } else { evt.preventDefault(); } - }, binding.modifiers.capture); - - let id; - let evt = null; - let dataTransfer = null; - function blockDragend() { - id = el.dataset.fhcDraggableValue; - dragendWorker.port.postMessage(['init', id]); - window.addEventListener('dragend', blockHandler, true); } - function unblockDragend(e) { - if (e) { - evt = e; - dataTransfer = e.dataTransfer; + + function onEnd(evt) { + if (blocked) { + blocked = { + evt, + dt: evt.dataTransfer + }; + evt.stopPropagation(); + el.dispatchEvent(new DragEvent("beforedragend", evt)); + } else { + bcc.onmessage = () => {}; } - window.removeEventListener('dragend', blockHandler, true); } + el.addEventListener('dragstart', onStart, binding.modifiers.capture); - function blockHandler(evt) { - if (evt.dataTransfer.dropEffect == 'none') - return unblockDragend(); - unblockDragend(evt); - evt.stopPropagation(); - dragendWorker.port.postMessage(['request']); - } + el.addEventListener('dragend', onEnd, true); - dragendWorker.port.onmessage = e => { - const [ func, ...args ] = e.data; - if (func != 'fire') - return; - const [ targetId ] = args; - if (targetId != id) - return; - if (evt === null) - unblockDragend(); - else - el.dispatchEvent(evt); - } + el.fhcDraggableCleanup = () => { + el.removeEventListener('dragstart', onStart, binding.modifiers.capture); + el.removeEventListener('dragend', onEnd, true); + }; }, updated(el, binding) { updateValue(el, binding.value); updateEffectAllowed(el, binding.arg); + }, + beforeUnmount(el) { + el.fhcDraggableCleanup(); + delete el.fhcDraggableCleanup; } } diff --git a/public/js/directives/drop.js b/public/js/directives/drop.js index 12253386c..35afa66de 100644 --- a/public/js/directives/drop.js +++ b/public/js/directives/drop.js @@ -1,4 +1,4 @@ -import { getValidTransferData, eventHasTypes, dragendWorker } from '../helpers/DragAndDrop.js'; +import { getValidTransferData, eventHasTypes, bindDragEnterLeave } from '../helpers/DragAndDrop.js'; const EFFECTS = [ 'move', @@ -24,34 +24,56 @@ export default { const arg = binding.arg.replace(/(strict-|-strict)/, ''); const effect = EFFECTS.includes(arg) ? arg : null; + const bcc = new BroadcastChannel('fhc-dnd'); let allowed = false; - el.addEventListener('dragenter', evt => { + function onEnter(evt) { allowed = eventHasTypes(evt, allowedTypes, strict); - if (allowed) + if (allowed) { evt.preventDefault(); - }); - el.addEventListener('dragover', evt => { + bcc.postMessage('block'); + } + } + function onLeave(evt, wasDropped) { + if (allowed && !wasDropped) { + bcc.postMessage('release'); + } + } + function onOver(evt) { if (allowed) { evt.preventDefault(); if (effect) evt.dataTransfer.dropEffect = effect; } - }); - el.addEventListener('drop', evt => { + } + function onDrop(evt) { let result = getValidTransferData(evt, allowedTypes, strict); if (!Array.isArray(result) && !binding.modifiers[result.type] && allowedTypes.includes(result.type + '-collection')) result = [result]; const res = binding.value(evt, result); + if (res instanceof Promise) { - const localId = id++; - dragendWorker.port.postMessage(['block', localId]); res.then(r => { - dragendWorker.port.postMessage(['unblock', localId]); + bcc.postMessage('release'); return r; }); + } else { + bcc.postMessage('release'); } - }); + } + + const cleanupEnterLeave = bindDragEnterLeave(el, onEnter, onLeave); + el.addEventListener('dragover', onOver); + el.addEventListener('drop', onDrop); + el.fhcDropCleanup = () => { + cleanupEnterLeave(); + el.removeEventListener('dragover', onOver); + el.removeEventListener('drop', onDrop); + }; + }, + beforeUnmount(el) { + el.fhcDropCleanup(); + delete el.fhcDropCleanup; } } diff --git a/public/js/helpers/DragAndDrop.js b/public/js/helpers/DragAndDrop.js index be068300c..8bf574746 100644 --- a/public/js/helpers/DragAndDrop.js +++ b/public/js/helpers/DragAndDrop.js @@ -2,8 +2,6 @@ * TODO(chris): This is only a prototype!!! */ -const dragendWorker = new SharedWorker(new URL("../sharedworkers/dragend.js", import.meta.url)); - const TYPE_DEFINITION = { lehreinheit: { id: "lehreinheit_id", @@ -119,12 +117,12 @@ function getTransferData(event, strict) { const result = []; for (const type of event.dataTransfer.types) { - if (type.substr(0, 4) != 'fhc/') { + if (type.substr(0, 16) != 'application/fhc-') { if (strict) return null; continue; } - let base_type = type.substr(4); + let base_type = type.substr(16); let collection = false; if (base_type.substr(-11) == '-collection') { base_type = base_type.substr(0, base_type.length-11); @@ -251,7 +249,7 @@ function setTransferData(event, validDragObject, setDragImage = false) { return validDragObject.forEach(data => setTransferData(event, data)); } - event.dataTransfer.setData('fhc/' + validDragObject.type, JSON.stringify(validDragObject)); + event.dataTransfer.setData('application/fhc-' + validDragObject.type, JSON.stringify(validDragObject)); } /** @@ -266,12 +264,85 @@ function setTransferData(event, validDragObject, setDragImage = false) { function eventHasTypes(event, allowedTypes, strict) { if (!allowedTypes || !allowedTypes.length) allowedTypes = VALID_TYPES; - allowedTypes = allowedTypes.map(type => 'fhc/' + type); + allowedTypes = allowedTypes.map(type => 'application/fhc-' + type); + + const dataTypes = [...event.dataTransfer.types]; + + // NOTE(chris): if dragging across browsers the dataTransfer object is + // set to a default one without data. Since we do not support dragging + // across browsers (yet) we return false which will disallow dropping. + if (!dataTypes.length) + return false; if (!strict) - return allowedTypes.some(type => event.dataTransfer.types.includes(type)); + return allowedTypes.some(type => [...event.dataTransfer.types].includes(type)); - return event.dataTransfer.types.every(type => allowedTypes.includes(type)); + return [...event.dataTransfer.types].every(type => allowedTypes.includes(type)); +} + +function bindDragEnterLeave(el, onEnter, onLeave) { + // NOTE(chris): add save dragenter and dragleave events + // that won't fire when hovering over child elements + + let skipLeave = false; + let skipLeaveParent = true; + + function init(evt) { + skipLeave = false; + skipLeaveParent = true; + // add global listeners + window.addEventListener('dragenter', globalDragenter, true); + window.addEventListener('dragleave', globalDragleave, true); + window.addEventListener('drop', globalDrop, true); + // call enter + onEnter(evt); + // remove self + el.removeEventListener('dragenter', init); + } + + function cleanup(evt, wasDropped) { + // remove global listeners + window.removeEventListener('dragenter', globalDragenter, true); + window.removeEventListener('dragleave', globalDragleave, true); + window.removeEventListener('drop', globalDrop, true); + // call leave + onLeave(evt, wasDropped); + // add init + el.addEventListener('dragenter', init); + } + + function globalDragenter(evt) { + skipLeaveParent = false; + if (el != evt.target && !el.contains(evt.target)) { + cleanup(evt); + } else { + skipLeave = true; + } + } + function globalDragleave(evt) { + if (el != evt.target && !el.contains(evt.target)) { + if (skipLeaveParent) { + skipLeaveParent = false; + return; + } + } else { + if (skipLeave) { + skipLeave = false; + return; + } + } + cleanup(evt); + } + function globalDrop(evt) { + cleanup(evt, true); + } + + el.addEventListener('dragenter', init); + + return () => { + // cleanup + el.removeEventListener('dragenter', init); + } } export { @@ -282,7 +353,7 @@ export { convertToValidDragObject, setTransferData, eventHasTypes, - dragendWorker + bindDragEnterLeave }; export default { ...TYPE_CONSTANTS, @@ -293,5 +364,5 @@ export default { convertToValidDragObject, setTransferData, eventHasTypes, - dragendWorker + bindDragEnterLeave }; diff --git a/public/js/sharedworkers/dragend.js b/public/js/sharedworkers/dragend.js deleted file mode 100644 index 0b80e972a..000000000 --- a/public/js/sharedworkers/dragend.js +++ /dev/null @@ -1,31 +0,0 @@ -let dragEndCallback = null; - -onconnect = e => { - const port = e.ports[0]; - - const cbList = []; - - port.onmessage = e => { - const [ func, ...args ] = e.data; - switch (func) { - case 'init': - dragEndCallback = () => { - port.postMessage(['fire', args]); - }; - break; - case 'block': - cbList[args[0]] = dragEndCallback; - dragEndCallback = null; - break; - case 'unblock': - cbList[args[0]](); - break; - case 'request': - if (dragEndCallback) { - dragEndCallback(); - dragEndCallback = null; - } - break; - } - }; -}; From 6658e80d998dee5602510713d8bbffb52cd874c0 Mon Sep 17 00:00:00 2001 From: chfhtw Date: Thu, 11 Sep 2025 14:58:20 +0200 Subject: [PATCH 10/10] code quality --- .../Stv/Studentenverwaltung/Details/Groups.js | 6 +++--- .../Studentenverwaltung/Details/Groups/Lvb.js | 1 - .../Details/Groups/Special.js | 2 +- .../components/Stv/Studentenverwaltung/List.js | 16 +++++++++++----- .../Stv/Studentenverwaltung/Verband.js | 4 ++-- public/js/directives/draggable.js | 2 +- public/js/directives/drop.js | 4 +--- public/js/helpers/DragAndDrop.js | 18 +++++++++--------- 8 files changed, 28 insertions(+), 25 deletions(-) diff --git a/public/js/components/Stv/Studentenverwaltung/Details/Groups.js b/public/js/components/Stv/Studentenverwaltung/Details/Groups.js index fab9511fe..45c0bf929 100644 --- a/public/js/components/Stv/Studentenverwaltung/Details/Groups.js +++ b/public/js/components/Stv/Studentenverwaltung/Details/Groups.js @@ -142,13 +142,13 @@ export default { } this.$api .call(endpoint) - .then(result => { + .then(() => { this.$fhcAlert.alertSuccess(this.$p.t('ui', 'successSave')); this.$reloadList(); }) .catch(this.$fhcAlert.handleSystemError); }, - addSpecialGroup(params) { + addSpecialGroup() { const gruppe_kurzbz = this.$refs.newGroupModal.value.gruppe_kurzbz || this.$refs.newGroupModal.value; if (Array.isArray(this.modelValue)) { @@ -183,7 +183,7 @@ export default { } else { this.$refs.newGroupModal.$refs.form .call(ApiStvGroups.add(this.modelValue.uid, gruppe_kurzbz, this.currentSemester)) - .then(result => { + .then(() => { this.$refs.newGroupModal.hide(); this.$fhcAlert.alertSuccess(this.$p.t('gruppenmanagement/groups_added', { n: 1 })); this.$refs.list.reload(); diff --git a/public/js/components/Stv/Studentenverwaltung/Details/Groups/Lvb.js b/public/js/components/Stv/Studentenverwaltung/Details/Groups/Lvb.js index db94d359c..8af63323b 100644 --- a/public/js/components/Stv/Studentenverwaltung/Details/Groups/Lvb.js +++ b/public/js/components/Stv/Studentenverwaltung/Details/Groups/Lvb.js @@ -162,7 +162,6 @@ export default { if (this.studiengang_kz === false) return; - let lvbList; this.$api .call(ApiStvLvb.getTree(this.studiengang_kz)) .then(result => this.lvbList = result.data) diff --git a/public/js/components/Stv/Studentenverwaltung/Details/Groups/Special.js b/public/js/components/Stv/Studentenverwaltung/Details/Groups/Special.js index 90b4c15ec..b6d9c8930 100644 --- a/public/js/components/Stv/Studentenverwaltung/Details/Groups/Special.js +++ b/public/js/components/Stv/Studentenverwaltung/Details/Groups/Special.js @@ -45,7 +45,7 @@ export default { this.hide(); } }, - onEnter(evt) { + onEnter() { /** * NOTE(chris): PrimeVue: AutoComplete: Enter does not submit form #5618 * @see https://github.com/primefaces/primevue/issues/5618 diff --git a/public/js/components/Stv/Studentenverwaltung/List.js b/public/js/components/Stv/Studentenverwaltung/List.js index 7c6f57f48..765009060 100644 --- a/public/js/components/Stv/Studentenverwaltung/List.js +++ b/public/js/components/Stv/Studentenverwaltung/List.js @@ -59,8 +59,12 @@ export default { {title:"Vornamen", field:"vornamen", visible:false, headerFilter: true}, {title:"TitelPost", field:"titelpost", headerFilter: "list", headerFilterParams: {valuesLookup:true, listOnEmpty:true, autocomplete:true, sort:"asc"}}, {title:"Ersatzkennzeichen", field:"ersatzkennzeichen", headerFilter: true}, - {title:"Geburtsdatum", field:"gebdatum", formatter:dateFormatter, - headerFilter: true, headerFilterFunc: function(headerValue, rowValue, rowData, filterParams) { + { + title: "Geburtsdatum", + field: "gebdatum", + formatter: dateFormatter, + headerFilter: true, + headerFilterFunc(headerValue, rowValue) { const matches = headerValue.match(/^(([0-9]{2})\.)?([0-9]{2})\.([0-9]{4})?$/); let comparestr = headerValue; if(matches !== null) { @@ -293,11 +297,13 @@ export default { onKeydown(e) { // TODO(chris): this should be in the filter component if (!this.focusObj) return; + + var next; switch (e.code) { case 'Enter': case 'Space': e.preventDefault(); - const e2 = new Event('click', e); + var e2 = new Event('click', e); e2.altKey = e.altKey; e2.ctrlKey = e.ctrlKey; e2.shiftKey = e.shiftKey; @@ -306,13 +312,13 @@ export default { break; case 'ArrowUp': e.preventDefault(); - var next = this.focusObj.previousElementSibling; + next = this.focusObj.previousElementSibling; if (next) this.changeFocus(this.focusObj, next); break; case 'ArrowDown': e.preventDefault(); - var next = this.focusObj.nextElementSibling; + next = this.focusObj.nextElementSibling; if (next) this.changeFocus(this.focusObj, next); break; diff --git a/public/js/components/Stv/Studentenverwaltung/Verband.js b/public/js/components/Stv/Studentenverwaltung/Verband.js index cfa7f0aed..db0070a37 100644 --- a/public/js/components/Stv/Studentenverwaltung/Verband.js +++ b/public/js/components/Stv/Studentenverwaltung/Verband.js @@ -240,7 +240,7 @@ export default { res += ' (' + student.vorname + ' ' + student.nachname + ')'; return res; }, - dropStudents(node, students, evt) { + dropStudents(node, students) { const data = node.data; let endpoint; @@ -335,7 +335,7 @@ export default { :data-tree-item-key="node.key" :title="node.data.studiengang_kz" v-drag-click="() => toggleTreeNode(node)" - v-drop:link-strict.student-collection="(evt, students) => dropStudents(node, students, evt)" + v-drop:link-strict.student-collection="(evt, students) => dropStudents(node, students)" > {{ node.data.name }} diff --git a/public/js/directives/draggable.js b/public/js/directives/draggable.js index 052fcb542..64747c15a 100644 --- a/public/js/directives/draggable.js +++ b/public/js/directives/draggable.js @@ -13,7 +13,7 @@ const EFFECTS = [ ]; export default { - mounted(el, binding, vnode) { + mounted(el, binding) { updateValue(el, binding.value); updateEffectAllowed(el, binding.arg); diff --git a/public/js/directives/drop.js b/public/js/directives/drop.js index 35afa66de..4c50e1b8e 100644 --- a/public/js/directives/drop.js +++ b/public/js/directives/drop.js @@ -7,10 +7,8 @@ const EFFECTS = [ 'none' ]; -let id = 0; - export default { - mounted(el, binding, vnode) { + mounted(el, binding) { const allowedTypes = Object.keys(binding.modifiers); allowedTypes.forEach(type => { if (type.substr(-11) == '-collection') { diff --git a/public/js/helpers/DragAndDrop.js b/public/js/helpers/DragAndDrop.js index 8bf574746..f98697eb2 100644 --- a/public/js/helpers/DragAndDrop.js +++ b/public/js/helpers/DragAndDrop.js @@ -50,20 +50,20 @@ function isValidDragObject(value) { return false; if (value.type.substr(-11) == '-collection') { - if (!value.hasOwnProperty('values')) + if (!Object.prototype.hasOwnProperty.call(value, 'values')) return false; if (!VALID_TYPES.includes(value.type.substr(0, value.type.length-11))) return false; } else { - if (!value.hasOwnProperty('id')) + if (!Object.prototype.hasOwnProperty.call(value, 'id')) return false; if (!VALID_TYPES.includes(value.type)) return false; if (TYPE_DEFINITION[value.type].extras) { - if (!TYPE_DEFINITION[value.type].extras.every(extra => value.hasOwnProperty(extra))) + if (!TYPE_DEFINITION[value.type].extras.every(extra => Object.prototype.hasOwnProperty.call(value, extra))) return false; } } @@ -99,7 +99,7 @@ function getValidTransferData(event, allowedTypes, strict) { return null; } } - } catch(error) { + } catch(_error) { return null; } @@ -175,15 +175,15 @@ function convertToValidDragObject(data, strict) { }); } - if (data.hasOwnProperty('type') && isValidDragObject(data)) { + if (Object.prototype.hasOwnProperty.call(data, 'type') && isValidDragObject(data)) { return data; } - const found = Object.entries(TYPE_DEFINITION).find(([type, typedef]) => { - if (!data.hasOwnProperty(typedef.id)) + const found = Object.entries(TYPE_DEFINITION).find(([ , typedef ]) => { + if (!Object.prototype.hasOwnProperty.call(data, typedef.id)) return false; if (typedef.extras) { - if (!typedef.extras.every(extra => data.hasOwnProperty("extra"))) + if (!typedef.extras.every(extra => Object.prototype.hasOwnProperty.call(data, extra))) return false; } return true; @@ -199,7 +199,7 @@ function convertToValidDragObject(data, strict) { newData.type = type; newData.id = data[typedef.id]; if (typedef.extras) - typedef.extras.forEach(extra => newData[extras] = data[extra]); + typedef.extras.forEach(extra => newData[extra] = data[extra]); return newData; }