/* eslint no-var: "error"*/ /* eslint no-console: "off"*/ /* eslint no-unused-vars: warn */ /* eslint max-len: ["error", { "code": 160 }] */ /* eslint promise/no-nesting: "off" */ /* eslint no-trailing-spaces: warn */ /* eslint max-depth: ["error", 6] */ /* eslint-env es6*/ import {SimpleLine} from "./simpleline/simpleline"; import {call} from 'core/ajax'; import notification from 'core/notification'; import {loadStringKeys, loadStrings, strformat} from './util/string-helper'; import {formatDate, addDays, datespaninfo} from './util/date-helper'; import {objCopy, transportItem} from './studyplan-processor'; import Debugger from './util/debugger'; import Config from 'core/config'; import {download, upload} from './downloader'; import {processStudyplan, processStudyplanPage} from './studyplan-processor'; import FitTextVue from './util/fittext-vue'; import {settings} from "./util/settings"; import TSComponents from './treestudyplan-components'; import mFormComponents from "./util/mform-helper"; import pSideBarComponents from "./util/psidebar-vue"; import {Drag, Drop, DropList} from './vue-easy-dnd/vue-easy-dnd.esm'; const STUDYPLAN_EDITOR_FIELDS = ['name', 'shortname', 'description', 'idnumber', 'context_id', 'aggregation', 'aggregation_config']; const PERIOD_EDITOR_FIELDS = ['fullname', 'shortname', 'startdate', 'enddate']; const LINE_GRAVITY = 1.3; export default { install(Vue/* ,options */) { Vue.component('drag', Drag); Vue.component('drop', Drop); Vue.component('drop-list', DropList); Vue.use(TSComponents); Vue.use(mFormComponents); Vue.use(pSideBarComponents); Vue.use(FitTextVue); let debug = new Debugger("treestudyplan-editor"); /* ********************************** * * * Treestudyplan Editor components * * * * **********************************/ /** * Check if element is visible * @param {Object} elem The element to check * @returns {boolean} True if visible */ function isVisible(elem) { return !!(elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length); } // Create new eventbus for interaction between item components const ItemEventBus = new Vue(); /* // Add event listener for the edit mode event so we can react to it, or at the very least ignore it document.addEventListener(editSwEventTypes.editModeSet,(e) => { e.preventDefault(); ItemEventBus.$emit('editModeSet', e.detail.editMode); }); */ let stringKeys = loadStringKeys({ conditions: [ {value: 'ALL', textkey: 'condition_all'}, {value: 'ANY', textkey: 'condition_any'}, ], }); let strings = loadStrings({ studyplanText: { 'studyline_editmode': 'studyline_editmode', 'toolbox_toggle': 'toolbox_toggle', 'editmode_modules_hidden': 'editmode_modules_hidden', 'studyline_add': 'studyline_add', add: 'add@core', edit: 'edit@core', 'delete': "delete@core", 'studyline_name': 'studyline_name', 'studyline_name_ph': 'studyline_name_ph', 'studyline_shortname': 'studyline_shortname', 'studyline_shortname_ph': 'studyline_shortname_ph', 'studyline_enrollable': 'studyline_enrollable', 'studyline_enrolroles': 'studyline_enrolroles', 'studyline_color': 'studyline_color', associations: 'associations', 'associated_cohorts': 'associated_cohorts', 'associated_users': 'associated_users', 'studyline_edit': 'studyline_edit', 'studyplan_name': 'studyplan_name', 'studyplan_name_ph': 'studyplan_name_ph', 'studyplan_shortname': 'studyplan_shortname', 'studyplan_shortname_ph': 'studyplan_shortname_ph', 'studyplan_description': 'studyplan_description', 'studyplan_description_ph': 'studyplan_description_ph', 'studyplan_idnumber': 'studyplan_idnumber', 'studyplan_idnumber_ph': 'studyplan_idnumber_ph', 'studyplan_slots': 'studyplan_slots', 'studyplan_startdate': 'studyplan_startdate', 'studyplan_enddate': 'studyplan_enddate', 'line_enrollable_0': 'line_enrollable:0', 'line_enrollable_1': 'line_enrollable:1', 'line_enrollable_2': 'line_enrollable:2', 'line_enrollable_3': 'line_enrollable:3', drophere: 'drophere', studylineConfirmRemove: 'studyline_confirm_remove', studyplanConfirmRemove: 'studyplan_confirm_remove', }, studyplanAdvanced: { 'advanced_tools': 'advanced_tools', 'confirm_cancel': 'confirm_cancel', 'confirm_ok': 'confirm_ok', success: 'success@core', error: 'failed@completion', 'advanced_converted': 'advanced_converted', 'advanced_skipped': 'advanced_skipped', 'advanced_failed': 'advanced_failed', 'advanced_locked': 'advanced_locked', 'advanced_multiple': 'advanced_multiple', 'advanced_error': 'advanced_error', 'advanced_tools_heading': 'advanced_tools_heading', 'advanced_warning_title': 'advanced_warning_title', 'advanced_warning': 'advanced_warning', 'advanced_pick_scale': 'advanced_pick_scale', 'advanced_course_manipulation_title': 'advanced_course_manipulation_title', 'advanced_bulk_course_timing': 'advanced_bulk_course_timing', 'advanced_bulk_course_timing_desc': 'advanced_bulk_course_timing_desc', 'advanced_force_scale_title': 'advanced_force_scale_title', 'advanced_force_scale_desc': 'advanced_force_scale_desc', 'advanced_force_scale_button': 'advanced_force_scale_button', 'advanced_confirm_header': 'advanced_confirm_header', 'advanced_force_scale_confirm': 'advanced_force_scale_confirm', 'advanced_backup_restore': 'advanced_backup_restore', 'advanced_restore': 'advanced_restore', 'advanced_backup': 'advanced_backup', 'advanced_restore_pages': 'advanced_restore_pages', 'advanced_restore_lines': 'advanced_restore_lines', 'advanced_backup_plan': 'advanced_backup_plan', 'advanced_backup_page': 'advanced_backup_page', 'advanced_export': 'advanced_export', 'advanced_export_csv_plan': 'advanced_export_csv_plan', 'advanced_export_csv_page': 'advanced_export_csv_page', 'advanced_import_from_file': 'advanced_import_from_file', 'advanced_purge': "advanced_purge", 'advanced_purge_plan': "advanced_purge_plan", 'advanced_purge_plan_expl': "advanced_purge_plan_expl", 'advanced_purge_page': "advanced_purge_page", 'advanced_purge_page_expl': "advanced_purge_page_expl", 'advanced_cascade_cohortsync_title': "advanced_cascade_cohortsync_title", 'advanced_cascade_cohortsync_desc': "advanced_cascade_cohortsync_desc", 'advanced_cascade_cohortsync': "advanced_cascade_cohortsync", currentpage: "currentpage", }, studyplanEdit: { studyplanEdit: 'studyplan_edit', 'studyplan_add': 'studyplan_add', 'studyplanpage_add': 'studyplanpage_add', 'studyplanpage_edit': 'studyplanpage_edit', 'info_periodsextended': 'studyplanpage_info_periodsextended', warning: 'warning@core', }, periodEdit: { edit: 'period_edit', fullname: 'studyplan_name', shortname: 'studyplan_shortname', startdate: 'studyplan_startdate', enddate: 'studyplan_enddate', }, courseTiming: { title: 'course_timing_title', desc: 'course_timing_desc', question: 'course_timing_question', warning: 'course_timing_warning', 'timing_ok': 'course_timing_ok', 'timing_off': 'course_timing_off', course: 'course@core', period: 'period', yes: 'yes$core', no: 'no$core', duration: 'duration', years: 'years$core', year: 'year$core', weeks: 'weeks$core', week: 'week$core', days: 'days$core', day: 'day$core', rememberchoice: 'course_timing_rememberchoice', hidewarning: 'course_timing_hidewarning', periodspan: 'course_period_span', periods: 'periods', 'periodspan_desc': 'course_period_span_desc', }, studyplanAssociate: { 'associations': 'associations', 'associated_cohorts': 'associated_cohorts', 'associated_users': 'associated_users', 'associated_coaches': 'associated_coaches', 'associate_cohorts': 'associate_cohorts', 'associate_users': 'associate_users', 'associate_coached': 'associate_coaches', 'add_association': 'add_association', 'delete_association': 'delete_association', 'associations_empty': 'associations_empty', 'associations_search': 'associations_search', cohorts: 'cohorts', users: 'users', coaches: 'coaches', selected: 'selected', name: 'name', context: 'context', search: 'search', }, itemText: { 'select_conditions': "select_conditions", 'item_configuration': "item_configuration", ok: "ok@core", 'delete': "delete@core", 'item_delete_message': "item_delete_message", 'type_course': "course@core", 'type_junction': "tool-junction", 'type_start': "tool-start", 'type_finish': "tool-finish", 'type_badge': "tool-badge", 'type_invalid': "course-invalid", }, itemCourseText: { 'select_conditions': "select_conditions", 'select_grades': "select_grades", 'coursetiming_past': "coursetiming_past", 'coursetiming_present': "coursetiming_present", 'coursetiming_future': "coursetiming_future", 'grade_include': "grade_include", 'grade_require': "grade_require", ok: "ok@core", cancel: "cancel@core", 'delete': "delete@core", noenddate: "noenddate", }, invalid: { error: 'error', }, completion: { 'completion_completed': "completion_completed", 'completion_incomplete': "completion_incomplete", 'aggregation_all': "aggregation_all", 'aggregation_any': "aggregation_any", 'aggregation_overall_all': "aggregation_overall_all", 'aggregation_overall_any': "aggregation_overall_any", 'completion_not_configured': "completion_not_configured", 'configure_completion': "configure_completion", }, competency: { 'competency_not_configured': "competency_not_configured", 'configure_competency': "configure_competency", when: "when", required: "required", points: "points@core_grades", heading: "competency_heading", details: "competency_details", }, badge: { 'share_badge': "share_badge", dateissued: "dateissued", dateexpire: "dateexpire", badgeinfo: "badgeinfo", }, toolbox: { toolbox: 'toolbox', toolbarRight: 'toolbar-right', courses: 'courses', flow: 'flow', toolJunction: 'tool-junction', toolFinish: 'tool-finish', toolStart: 'tool-start', badges: 'badges', relatedbages: 'relatedbages@badges', filter: 'filter@core', sitebadges: 'sitebadges@badges', } }); /* * T-STUDYPLAN-ADVANCED */ Vue.component('t-studyplan-advanced', { props: { value: { type: Object, default() { return null; }, }, selectedpage: { type: Object, default() { return null; }, } }, data() { return { forceScales: { selectedScale: null, result: [], }, text: strings.studyplanAdvanced, }; }, computed: { scales() { return [{ id: null, disabled: true, name: this.text.advanced_pick_scale, }].concat(this.value.advanced.force_scales.scales); }, }, methods: { forceScalesStart() { // Set confirmation box const self = this; this.$bvModal.msgBoxConfirm(this.text.advanced_force_scale_confirm, { title: this.text.advanced_force_scale_confirm, okVariant: 'danger', okTitle: this.text.confirm_ok, cancelTitle: this.text.confirm_cancel, }).then(value => { if (value == true) { call([{ methodname: 'local_treestudyplan_force_studyplan_scale', args: { 'studyplan_id': this.value.id, 'scale_id': this.forceScales.selectedScale, } }])[0].then((response) => { self.forceScales.result = response; return; }).catch(notification.exception); } return; }).catch(notification.exception); }, exportPage(format) { const self = this; if (format == undefined || !["json", "csv"].includes(format)) { format = "json"; } call([{ methodname: 'local_treestudyplan_export_page', args: { 'page_id': this.selectedpage.id, format: format, }, }])[0].then((response) => { download(self.value.shortname + ".page." + format, response.content, response.format); return; }).catch(notification.exception); }, exportPlan() { const self = this; call([{ methodname: 'local_treestudyplan_export_plan', args: { 'studyplan_id': this.value.id, format: "json", }, }])[0].then((response) => { download(self.value.shortname + ".plan.json", response.content, response.format); return; }).catch(notification.exception); }, bulkCourseTiming() { const self = this; call([{ methodname: 'local_treestudyplan_bulk_course_timing', args: { 'page_id': this.selectedpage.id, }, }])[0].then((response) => { if (response.success) { // Reloading the webpage saves trouble reloading the specific page updated. location.reload(); } else { self.$bvModal.msgBoxOk(response.msg, {title: "Could not set bulk course timing"}); debug.error("Could not set bulk course timing: ", response.msg); } return; }).catch(notification.exception); }, importStudylines() { const self = this; upload((filename, content)=>{ call([{ methodname: 'local_treestudyplan_import_studylines', args: { 'page_id': this.selectedpage.id, content: content, format: "application/json", }, }])[0].then((response) => { if (response.success) { location.reload(); } else { self.$bvModal.msgBoxOk(response.msg, {title: "Import failed"}); debug.error("Import failed: ", response.msg); } return; }).catch(notification.exception); }, "application/json"); }, importPages() { const self = this; upload((filename, content)=>{ call([{ methodname: 'local_treestudyplan_import_pages', args: { 'studyplan_id': this.value.id, content: content, format: "application/json", }, }])[0].then((response) => { if (response.success) { location.reload(); } else { self.$bvModal.msgBoxOk(response.msg, {title: "Import failed"}); debug.error("Import failed: ", response.msg); } return; }).catch(notification.exception); }, "application/json"); }, purgeStudyplan() { const self = this; call([{ methodname: 'local_treestudyplan_delete_studyplan', args: { id: this.value.id, force: true, }, }])[0].then((response) => { if (response.success) { location.reload(); } else { self.$bvModal.msgBoxOk(response.msg, {title: "Could not delete plan "}); debug.error("Could not delete plan: ", response.msg); } return; }).catch(notification.exception); }, purgeStudyplanpage() { const self = this; if (this.selectedpage) { call([{ methodname: 'local_treestudyplan_delete_studyplanpage', args: { id: this.selectedpage.id, force: true, }, }])[0].then((response) => { if (response.success) { location.reload(); } else { self.$bvModal.msgBoxOk(response.msg, {title: "Could not delete page"}); debug.error("Could not delete page: ", response.msg); } return; }).catch(notification.exception); } }, cascadeCohortsync() { const self = this; call([{ methodname: 'local_treestudyplan_cascade_cohortsync', args: { 'studyplan_id': this.value.id, }, }])[0].then((response) => { self.$bvModal.msgBoxOk(response.success ? self.text.success : self.text.error, {title: self.text.advanced_cascade_cohortsync}); return; }).catch(notification.exception); }, modalClose() { this.forceScales.result = []; } }, template: ` {{text.advanced_tools}} {{ text.advanced_warning}}

{{ text.advanced_cascade_cohortsync_title}}

{{ text.advanced_cascade_cohortsync_desc}}

{{ text.advanced_cascade_cohortsync}}

{{ text.advanced_bulk_course_timing}}

{{ text.advanced_bulk_course_timing_desc}}

{{text.currentpage}} {{selectedpage.fullname}}

{{ text.advanced_bulk_course_timing}}

{{ text.advanced_backup }}

{{ text.advanced_backup_page }} {{text.currentpage}} {{selectedpage.fullname}}

{{ text.advanced_backup_plan }}

{{ text.advanced_restore }}

{{ text.advanced_restore_lines}}

{{ text.advanced_restore_pages }}

{{ text.advanced_export }}

{{ text.advanced_export_csv_page }} {{text.currentpage}} {{selectedpage.fullname}}

{{text.advanced_purge_page_expl}}

{{text.currentpage}} {{selectedpage.fullname}}

{{ text.advanced_purge_page}}

{{text.advanced_purge_plan_expl}}

{{ text.advanced_purge_plan}}

` }); /* * T-STUDYPLAN-EDIT */ Vue.component('t-studyplan-edit', { props: { 'value': { type: Object, default() { return null; }, }, 'mode': { type: String, default() { return "edit"; }, }, 'type': { type: String, default() { return "link"; }, }, 'variant': { type: String, default() { return ""; }, }, 'contextid': { type: Number, 'default': 1 }, }, data() { return { text: strings.studyplanEdit, }; }, computed: { }, methods: { planSaved(updatedplan) { const self = this; debug.info("Got new plan data", updatedplan); if (self.mode == 'create') { // Inform parent of the details of the newly created plan self.$emit("created", updatedplan); } else { // Determine if the plan moved context... const movedFrom = self.value.context_id; const movedTo = updatedplan.context_id; const moved = (movedFrom != movedTo); if (updatedplan.pages[0].periods != self.value.pages[0].periods) { // If the pages changed, just reload the entire model for the plan call([{ methodname: 'local_treestudyplan_get_studyplan_map', args: {id: self.value.id} }])[0].then((response) => { self.value = processStudyplan(response, true); debug.info('studyplan processed'); self.$emit('input', self.value); return; }).catch(function(error) { notification.exception(error); }); } else { // Copy updated fields and trigger update objCopy(self.value, updatedplan, STUDYPLAN_EDITOR_FIELDS); self.$emit('input', self.value); if (moved) { self.$emit('moved', self.value, movedFrom, movedTo); } } } }, }, template: ` ` }); /* * T-STUDYPLAN-EDIT */ Vue.component('t-studyplan-page-edit', { props: { 'value': { type: Object, default() { return null; }, }, 'mode': { type: String, default() { return "edit"; }, }, 'type': { type: String, default() { return "link"; }, }, 'variant': { type: String, default() { return ""; }, }, 'studyplan': { type: Object, }, }, data() { return { text: strings.studyplanEdit, }; }, computed: { }, methods: { planSaved(updatedpage) { const self = this; if (self.mode == 'create') { // Inform parent of the details of the newly created plan self.$emit("created", updatedpage); } else { const page = processStudyplanPage(updatedpage); debug.info('studyplan page processed'); if (self.value.periods < page.periods) { this.$bvModal.msgBoxOk(this.text.info_periodsextended, { title: this.text.warning, okVariant: 'success', centered: true }); } self.$emit('input', page); } }, }, template: ` ` }); /* * T-STUDYPLAN-ASSOCIATE */ Vue.component('t-studyplan-associate', { props: ['value'], data() { return { show: false, config: { userfields: [ {key: "selected"}, {key: "firstname", "sortable": true}, {key: "lastname", "sortable": true}, ], cohortfields: [ {key: "selected"}, {key: "name", "sortable": true}, {key: "context", "sortable": true}, ] }, association: { cohorts: [], users: [], coaches: [] }, loading: { cohorts: false, users: false, coaches: false, }, search: {users: [], cohorts: [], coaches: []}, selected: { search: {users: [], cohorts: [], coaches: []}, associated: {users: [], cohorts: [], coaches: []} }, text: strings.studyplanAssociate, }; }, methods: { showModal() { this.show = true; this.loadAssociations(); }, cohortOptionModel(c) { return { value: c.id, text: c.name + ' (' + c.context.path.join(' / ') + ')', }; }, userOptionModel(u) { return { value: u.id, text: u.firstname + ' ' + u.lastname, }; }, loadAssociations() { const self = this; self.loading.cohorts = true; self.loading.users = true; call([{ methodname: 'local_treestudyplan_associated_users', args: { 'studyplan_id': self.value.id, } }])[0].then((response) => { self.association.users = response.map(self.userOptionModel); self.loading.users = false; return; }).catch(notification.exception); call([{ methodname: 'local_treestudyplan_associated_cohorts', args: { 'studyplan_id': self.value.id, } }])[0].then((response) => { self.association.cohorts = response.map(self.cohortOptionModel); self.loading.cohorts = false; return; }).catch(notification.exception); self.loading.coaches = true; call([{ methodname: 'local_treestudyplan_associated_coaches', args: { 'studyplan_id': self.value.id, } }])[0].then((response) => { self.association.coaches = response.map(self.userOptionModel); self.loading.coaches = false; return; }).catch(notification.exception); }, searchCohorts(searchtext) { const self = this; if (searchtext.length > 0) { call([{ methodname: 'local_treestudyplan_list_cohort', args: { like: searchtext, 'studyplan_id': self.value.id } }])[0].then((response) => { self.search.cohorts = response.map(self.cohortOptionModel); return; }).catch(notification.exception); } else { self.search.cohorts = []; } }, cohortAssociate() { const self = this; let requests = []; const associated = self.association.cohorts; const search = self.search.cohorts; const searchselected = self.selected.search.cohorts; for (const i in searchselected) { const r = searchselected[i]; call([{ methodname: 'local_treestudyplan_connect_cohort', args: { 'studyplan_id': self.value.id, 'cohort_id': r, }, }])[0].then((response) => { if (response.success) { transportItem(associated, search, r); } return; }).catch(notification.exception); } call(requests); }, cohortDisassociate() { const self = this; const associatedselected = self.selected.associated.cohorts; const associated = self.association.cohorts; const search = self.search.cohorts; for (const i in associatedselected) { const r = associatedselected[i]; call([{ methodname: 'local_treestudyplan_disconnect_cohort', args: { 'studyplan_id': self.value.id, 'cohort_id': r, } }])[0].then((response) => { if (response.success) { transportItem(search, associated, r); } return; }).catch(notification.exception); } }, searchUsers(searchtext) { const self = this; if (searchtext.length > 0) { call([{ methodname: 'local_treestudyplan_find_user', args: { like: searchtext, 'studyplan_id': self.value.id } }])[0].then((response) => { self.search.users = response.map(self.userOptionModel); return; }).catch(notification.exception); } else { self.search.users = []; } }, userAssociate() { const self = this; const associated = self.association.users; const search = self.search.users; const searchselected = self.selected.search.users; for (const i in searchselected) { const r = searchselected[i]; call([{ methodname: 'local_treestudyplan_connect_user', args: { 'studyplan_id': self.value.id, 'user_id': r, }, }])[0].then((response) => { if (response.success) { transportItem(associated, search, r); } return; }).catch(notification.exception); } }, userDisassociate() { const self = this; const associated = self.association.users; const associatedselected = self.selected.associated.users; const search = self.search.users; for (const i in associatedselected) { const r = associatedselected[i]; call([{ methodname: 'local_treestudyplan_disconnect_user', args: { 'studyplan_id': self.value.id, 'user_id': r, } }])[0].then((response) => { if (response.success) { transportItem(search, associated, r); } return; }).catch(notification.exception); } }, searchCoaches(searchtext) { const self = this; if (searchtext.length > 0) { call([{ methodname: 'local_treestudyplan_find_coach', args: { like: searchtext, 'studyplan_id': self.value.id, } }])[0].then((response) => { self.search.coaches = response.map(self.userOptionModel); return; }).catch(notification.exception); } else { self.search.coaches = []; } }, coachAssociate() { const self = this; const associated = self.association.coaches; const search = self.search.coaches; const searchselected = self.selected.search.coaches; for (const i in searchselected) { const r = searchselected[i]; call([{ methodname: 'local_treestudyplan_connect_coach', args: { 'studyplan_id': self.value.id, 'user_id': r, }, }])[0].then((response) => { if (response.success) { transportItem(associated, search, r); } return; }).catch(notification.exception); } }, coachDisassociate() { const self = this; const associatedselected = self.selected.associated.coaches; for (const i in associatedselected) { const r = associatedselected[i]; call([{ methodname: 'local_treestudyplan_disconnect_coach', args: { 'studyplan_id': self.value.id, 'user_id': r, } }])[0].then(() => { return; }).catch(notification.exception); } }, }, template: ` {{text.associated_cohorts}} {{text.associate_cohorts}}  {{text.delete_association}}  {{text.add_association}} {{text.associated_users}} {{text.associate_users}}  {{text.delete_association}}  {{text.add_association}} {{text.associated_coaches}} {{text.associate_coaches}}  {{text.delete_association}}  {{text.add_association}} ` }); /* * **************** * * Period editor * *************/ Vue.component('t-period-edit', { props: { 'value': { type: Object, default() { return null; }, }, 'type': { type: String, default() { return "link"; }, }, 'variant': { type: String, default() { return ""; }, }, 'minstart': { type: String, default() { return null; }, }, 'maxend': { type: String, default() { return null; }, } }, data() { return { show: false, editdata: { fullname: '', shortname: '', startdate: (new Date()).getFullYear() + '-08-01', enddate: ((new Date()).getFullYear() + 1) + '-08-01', }, text: strings.periodEdit, }; }, methods: { editStart() { objCopy(this.editdata, this.value, PERIOD_EDITOR_FIELDS); this.show = true; }, editFinish() { const self = this; let args = {'id': this.value.id}; objCopy(args, this.editdata, PERIOD_EDITOR_FIELDS); call([{ methodname: 'local_treestudyplan_edit_period', args: args }])[0].then((response) => { objCopy(self.value, response, PERIOD_EDITOR_FIELDS); self.$emit('input', self.value); self.$emit('edited', self.value); return; }).catch(notification.exception); }, refresh() { const self = this; call([{ methodname: 'local_treestudyplan_get_period', args: {'id': this.value.id}, }])[0].then((response) => { objCopy(self.value, response, PERIOD_EDITOR_FIELDS); self.$emit('input', self.value); return; }).catch(notification.exception); }, addDay(date, days) { if (days === undefined) { days = 1; } return addDays(date, days); }, subDay(date, days) { if (days === undefined) { days = 1; } return addDays(date, 0 - days); }, }, template: ` {{ text.fullname}} {{ text.shortname}} {{ text.studyplan_startdate}} {{ text.studyplan_enddate}} ` }); // TAG: Start studyplan component /* * T-STUDYPLAN */ Vue.component('t-studyplan', { props: { 'value': { type: Object, }, 'coaching': { type: Boolean, 'default': false, }, }, data() { return { config: { userfields: [ {key: "selected"}, {key: "firstname", "sortable": true}, {key: "lastname", "sortable": true}, ], cohortfields: [ {key: "selected"}, {key: "name", "sortable": true}, {key: "context", "sortable": true}, ] }, create: { studyline: { name: '', shortname: '', color: '#DDDDDD', enrol: { enrollable: 0, enrolroles: [], } }, page: { id: -1, name: '', shortname: '' } }, edit: { 'toolbox_shown': false, studyline: { editmode: false, data: { name: '', shortname: '', color: '#DDDDDD', enrol: { enrollable: 0, enrolroles: [], } }, original: {}, availableroles: [], }, studyplan: { data: { name: '', shortname: '', description: '', slots: 4, startdate: '2020-08-01', enddate: '', aggregation: '', 'aggregation_config': '', 'aggregation_info': { useRequiredGrades: true, useItemCondition: false, }, }, original: {}, } }, text: strings.studyplanText, cache: { linelayers: {}, }, selectedpageindex: 0, emptyline: { id: -1, name: '', shortname: '', color: '#FF0000', filterslots: [{}], courseslots: [{}] }, availableroles: [], }; }, created() { const self = this; // Listener for the signal that a new connection was made and needs to be drawn // Sent by the incoming item - By convention, outgoing items are responsible for drawing the lines ItemEventBus.$on('coursechange', () => { self.$emit('pagechanged', this.selectedpage); }); }, mounted() { const self = this; if (this.value.pages[0].studylines.length == 0 && !this.coaching) { // Start in editmode if studylines on first page are empty this.edit.studyline.editmode = true; } if (!self.coaching) { // Retrieve available roles (only needed as manager) call([{ methodname: 'local_treestudyplan_list_roles', args: { 'studyplan_id': this.value.id, } }])[0].then((response) => { self.availableroles = response; return; }).catch(notification.exception); } this.$root.$emit('redrawLines'); this.$emit('pagechanged', this.selectedpage); }, updated() { this.$root.$emit('redrawLines'); ItemEventBus.$emit('redrawLines'); }, computed: { selectedpage() { return this.value.pages[this.selectedpageindex]; }, hivizdrop() { return settings("hivizdropslots"); }, }, methods: { columns(page) { return 1 + (page.periods * 2); }, columnsStylerule(page) { // Uses css variables, so width for slots and filters can be configured in css let s = "grid-template-columns: var(--studyplan-filter-width)"; // Use css variable here for (let i = 0; i < page.periods; i++) { s += " var(--studyplan-course-width) var(--studyplan-filter-width)"; } return s + ";"; }, trashbinAccepts(type) { if (type.item) { return true; } else { return false; } }, countLineLayers(line, page) { // For some optimization, we cache the value of this calculation for about a second // Would be a lot nicer if we could use a computed property for this..... if (this.cache.linelayers[line.id] && ((new Date()) - this.cache.linelayers[line.id].timestamp < 1000) ) { return this.cache.linelayers[line.id].value; } else { let maxLayer = -1; for (let i = 0; i <= page.periods; i++) { if (line.slots[i]) { // Determine the amount of used layers in a studyline slot for (const ix in line.slots[i].courses) { const item = line.slots[i].courses[ix]; if (item.layer > maxLayer) { maxLayer = item.layer; } } for (const ix in line.slots[i].filters) { const item = line.slots[i].filters[ix]; if (item.layer > maxLayer) { maxLayer = item.layer; } } } } this.cache.linelayers[line.id] = { value: (maxLayer + 1), timestamp: (new Date()), }; return maxLayer + 1; } }, slotsempty(slots) { if (Array.isArray(slots)) { let count = 0; for (let i = 0; i < slots.length; i++) { if (Array.isArray(slots[i].courses)) { count += slots[i].courses.length; } if (Array.isArray(slots[i].filters)) { count += slots[i].filters.length; } } return (count == 0); } else { return false; } }, movedStudyplan(plan, from, to) { this.$emit('moved', plan, from, to); // Throw the event up.... }, addStudyLine(page, newlineinfo) { call([{ methodname: 'local_treestudyplan_add_studyline', args: { 'page_id': page.id, 'name': newlineinfo.name, 'shortname': newlineinfo.shortname, 'color': newlineinfo.color, 'sequence': page.studylines.length, 'enrollable': newlineinfo.enrol.enrollable, 'enrolroles': newlineinfo.enrol.enrolroles } }])[0].then((response) => { page.studylines.push(response); newlineinfo.name = ''; newlineinfo.shortname = ''; newlineinfo.color = "#dddddd"; newlineinfo.enrol.enrollable = 0; newlineinfo.enrol.enrolroles = []; return; }).catch(notification.exception); }, editLineStart(line) { const page = this.value.pages[this.selectedpageindex]; debug.info("Starting line edit", line); Object.assign(this.edit.studyline.data, line); this.edit.studyline.original = line; this.$bvModal.show('modal-edit-studyline-' + page.id); }, editLineFinish() { let editedline = this.edit.studyline.data; let originalline = this.edit.studyline.original; call([{ methodname: 'local_treestudyplan_edit_studyline', args: {'id': editedline.id, 'name': editedline.name, 'shortname': editedline.shortname, 'color': editedline.color, 'enrollable': editedline.enrol.enrollable, 'enrolroles': editedline.enrol.enrolroles } }])[0].then((response) => { originalline.name = response.name; originalline.shortname = response.shortname; originalline.color = response.color; originalline.enrol.enrollable = response.enrol.enrollable; originalline.enrol.enrolroles = response.enrol.enrolroles; return; }).catch(notification.exception); }, deleteLine(page, line) { const self = this; self.$bvModal.msgBoxConfirm(this.text.studylineConfirmRemove.replace('{$a}', line.name), { okTitle: this.text.delete, okVariant: 'danger', }).then((modalresponse) => { if (modalresponse) { call([{ methodname: 'local_treestudyplan_delete_studyline', args: {'id': line.id} }])[0].then((response) => { if (response.success == true) { let index = page.studylines.indexOf(line); page.studylines.splice(index, 1); } return; }).catch(notification.exception); } return; }).catch(notification.exception); }, reorderLines(event, lines) { // Apply reordering event.apply(lines); // Send the new sequence to the server let sequence = []; for (let idx in lines) { sequence.push({'id': lines[idx].id, 'sequence': idx}); } call([{ methodname: 'local_treestudyplan_reorder_studylines', args: {'sequence': sequence} }])[0].then(() => { return; }).catch(notification.exception); }, deletePlan(studyplan) { const self = this; self.$bvModal.msgBoxConfirm(this.text.studyplabConfirmRemove.replace('{$a}', studyplan.name), { okTitle: this.text.delete, okVariant: 'danger', }).then(function(modalresponse) { if (modalresponse) { call([{ methodname: 'local_treestudyplan_delete_studyplan', args: {'id': studyplan.id, force: true} }])[0].then((response) => { if (response.success == true) { self.$root.$emit("studyplanRemoved", studyplan); } return; }).catch(notification.exception); } return; }).catch(notification.exception); }, deleteStudyItem(event) { let item = event.data; call([{ methodname: 'local_treestudyplan_delete_studyitem', args: {'id': item.id} }])[0].then((response) => { if (response.success == true) { event.source.$emit('cut', event); } return; }).catch(notification.exception); }, showslot(page, line, index, layeridx, type) { // Check if the slot should be hidden because a previous slot has an item with a span // so big that it hides this slot const forGradable = (type == 'gradable') ? true : false; const periods = page.periods; let show = true; for (let i = 0; i < periods; i++) { if (line.slots[index - i] && line.slots[index - i].courses) { const list = line.slots[index - i].courses; for (const ix in list) { const item = list[ix]; if (item.layer == layeridx) { if (forGradable) { if (i > 0 && (item.span - i) > 0) { show = false; } } else { if ((item.span - i) > 1) { show = false; } } } } } } return show; }, periodEdited(pi) { const prev = this.$refs["periodeditor-" + (pi.period - 1)]; if (prev && prev[0]) { prev[0].refresh(); } const next = this.$refs["periodeditor-" + (pi.period + 1)]; if (next && next[0]) { next[0].refresh(); } }, addDay(date, days) { if (days === undefined) { days = 1; } return addDays(date, days); }, subDay(date, days) { if (days === undefined) { days = 1; } return addDays(date, 0 - days); }, toolboxSwitched(event) { this.$emit('toggletoolbox', event); }, pagecreated(page) { this.value.pages.push(page); }, selectedpageChanged(newTabIndex /* , prevTabIndex*/) { const page = this.value.pages[newTabIndex]; this.$emit('pagechanged', page); }, sumLineLayers(idx, page) { if (idx < 0 || page.studylines.count == 0) { return 0; } else { let sum = 0; for (let i = 0; i < idx; i++) { sum += this.countLineLayers(page.studylines[i], page) + 1; } return sum; } }, span(line, slot, layer) { let span = 1; for (const course of line.slots[slot].courses) { if (course.slot == slot && course.layer == layer) { span = course.span; } } return span; }, onDrop(event, line, slot) { debug.info("dropping", event, line, slot); const self = this; if (event.type.component) { // Double check in case filter fails debug.info("Adding new component"); if (event.type.type == "gradable") { // Determine first available layer; const lineslot = line.slots[slot].courses; let nextlayer = 0; for (const itm of lineslot) { if (itm.layer >= nextlayer) { nextlayer = itm.layer + 1; } } call([{ methodname: 'local_treestudyplan_add_studyitem', args: { "line_id": line.id, "slot": slot, "layer": nextlayer, "type": 'course', "details": { "competency_id": null, 'conditions': '', 'course_id': event.data.id, 'badge_id': null, 'continuation_id': null, } } }])[0].then((response) => { let item = response; lineslot.push(item); self.$emit("input", self.value); // Call the validate period function on next tick, // since it paints the item in the slot first this.$nextTick(() => { if (this.$refs.timingChecker) { this.$refs.timingChecker.validateCoursePeriod(); } }); ItemEventBus.$emit('coursechange'); return; }).catch(notification.exception); } else if (event.type.type == "filter") { debug.info("Adding new filter compenent"); // Determine first available layer; const lineslot = line.slots[slot].filters; let nextlayer = 0; for (const itm of lineslot) { if (itm.layer >= nextlayer) { nextlayer = itm.layer + 1; } } call([{ methodname: 'local_treestudyplan_add_studyitem', args: { "line_id": line.id, "slot": slot, "type": event.data.type, "layer": nextlayer, "details": { "badge_id": event.data.badge ? event.data.badge.id : undefined, } } }])[0].then((response) => { let item = response; lineslot.push(item); self.$emit("input", self.value); return; }).catch(notification.exception); } } }, checkTypeCourse(type) { if (type.type == "gradable") { if (settings("hivizdropslots") && !type.item) { return true; } else { return false; } } else { return false; } }, checkTypeFilter(type) { if (type.type == "filter") { if (settings("hivizdropslots") && !type.item) { return true; } else { return false; } } else { return false; } } }, template: `
{{ text.studyline_editmode }} {{ text.toolbox_toggle}}
{{text.associations}} {{text.edit}}
{{text.studyline_name}} {{text.studyline_shortname}} {{text.studyline_color}} {{ text.studyline_enrollable}} {{text['line_enrollable_'+n]}} {{ text.studyline_enrolroles}} {{ text.studyline_name}} {{ text.studyline_shortname}} {{ text.studyline_color}} {{ text.studyline_enrollable}} {{text['line_enrollable_'+n]}} {{ text.studyline_enrolroles}}
` }); /* * T-STUDYLINE-HEADER */ Vue.component('t-studyline-heading', { props: { value: { type: Object, // Studyline default() { return {}; }, }, layers: { type: Number, 'default': 1, }, }, data() { return { layerHeights: {} }; }, created() { // Listener for the signal that a new connection was made and needs to be drawn // Sent by the incoming item - By convention, outgoing items are responsible for drawing the lines ItemEventBus.$on('lineHeightChange', this.onLineHeightChange); }, computed: { }, methods: { onLineHeightChange(lineid) { // Is called with parameters (lineid, layerid, newheight) // All layers for this line have the first slot send an update message on layer height change. // When one of those updates is received, record the height and recalculate the total height of the // header if (this.$refs.main && lineid == this.value.id) { const items = document.querySelectorAll( `.t-studyline-slot-0[data-studyline='${this.value.id}']`); // Determine the height of all the lines and add them up. let heightSum = 0; items.forEach((el) => { // Func getBoundingClientRect() Gets the actual fractional height instead of rounded to integer pixels const r = el.getBoundingClientRect(); const height = r.height; heightSum += height; }); const heightStyle = `${heightSum}px`; this.$refs.main.style.height = heightStyle; } } }, template: `
{{ value.shortname }}
`, }); /* * T-STUDYLINE (Used only for study line edit mode) */ Vue.component('t-studyline-edit', { props: { value: { type: Object, // Studyline default() { return {}; }, } }, data() { return { }; }, computed: { deletable() { // Check if all the slots are empty const slots = this.value.slots; if (Array.isArray(slots)) { let count = 0; for (let i = 0; i < slots.length; i++) { if (Array.isArray(slots[i].courses)) { count += slots[i].courses.length; } if (Array.isArray(slots[i].filters)) { count += slots[i].filters.length; } } return (count == 0); } else { return false; } }, editable() { return true; } }, methods: { onEdit() { this.$emit('edit', this.value); }, onDelete() { this.$emit('delete', this.value); }, }, template: `
{{ value.shortname }}
`, }); /* * During a redisign it was decided to have the studyline still get the entire array as a value, * even though it only shows one drop slot for the layer it is in. This is to make repainting easier, * since we modify the array for the layer we handle. FIXME: Make this less weird */ Vue.component('t-studyline-slot', { props: { type: { type: String, 'default': 'gradable', }, slotindex: { type: Number, 'default': '', }, line: { type: Object, default() { return null; }, }, layer: { type: Number, }, value: { type: Array, // Dict with layer as index default() { return []; }, }, plan: { type: Object, // Studyplan data default() { return null; }, }, page: { type: Object, // Studyplan data default() { return null; }, }, period: { type: Object, // Studyplan data default() { return null; }, }, }, mounted() { const self = this; if (self.type == "gradable" && self.slotindex == 1) { self.resizeListener = new ResizeObserver(() => { if (self.$refs.main) { const size = self.$refs.main.getBoundingClientRect(); ItemEventBus.$emit('lineHeightChange', self.line.id, self.layer, size.height); } }).observe(self.$refs.main); } }, unmounted() { if (this.resizeListener) { this.resizeListener.disconnect(); } }, computed: { slotkey() { return `${this.type}'-'${this.line.id}-${this.slotindex}-${this.layer}`; }, itemidx() { for (const ix in this.value) { const itm = this.value[ix]; if (itm.layer == this.layer) { return ix; } } return null; }, item() { for (const ix in this.value) { const itm = this.value[ix]; if (itm.layer == this.layer) { return itm; } } return null; }, listtype() { return this.type; }, courseHoverDummy() { return {course: this.hover.component}; }, spanCss() { if (this.item && this.item.span > 1) { // Calculate span like this: // const span = (2 * this.item.span) - 1; return `width: 100%; `; } else { return ""; } } }, data() { return { text: strings.courseTiming, plantext: strings.studyplanText, resizeListener: null, hover: { component: null, type: null, }, datechanger: { coursespan: null, periodspan: null, 'default': false, defaultchoice: false, hidewarn: false, } }; }, methods: { hivizdrop() { return settings("hivizdropslots"); }, onDrop(event) { this.hover.component = null; this.hover.type = null; debug.info(event); const self = this; if (event.type.item) { let item = event.data; // To avoid weird visuals with the lines, // we add the item to the proper place in the front-end first item.layer = this.layer; item.slot = this.slotindex; self.value.push(item); self.$emit("input", self.value); // Then on the next tick, we inform the back end // Since moving things around has never been unsuccessful, unless you have other problems, // it's better to have nice visuals. self.relocateStudyItem(item).then(() => { if (this.$refs.timingChecker) { this.$refs.timingChecker.validateCoursePeriod(); } return; }).catch(notification.exception); } else if (event.type.component) { debug.info("Adding new component"); if (event.type.type == "gradable") { call([{ methodname: 'local_treestudyplan_add_studyitem', args: { "line_id": self.line.id, "slot": self.slotindex, "layer": self.layer, "type": 'course', "details": { "competency_id": null, 'conditions': '', 'course_id': event.data.id, 'badge_id': null, 'continuation_id': null, } } }])[0].then((response) => { let item = response; self.relocateStudyItem(item).then(()=>{ self.value.push(item); self.$emit("input", self.value); // Call the validate period function on next tick, // since it paints the item in the slot first this.$nextTick(() => { if (this.$refs.timingChecker) { this.$refs.timingChecker.validateCoursePeriod(); } }); ItemEventBus.$emit('coursechange'); return; }).catch(notification.exception); return; }).catch(notification.exception); } else if (event.type.type == "filter") { debug.info("Adding new filter compenent"); call([{ methodname: 'local_treestudyplan_add_studyitem', args: { "line_id": self.line.id, "slot": self.slotindex, "type": event.data.type, "details": { "badge_id": event.data.badge ? event.data.badge.id : undefined, } } }])[0].then((response) => { let item = response; self.relocateStudyItem(item).then(() => { item.layer = this.layer; self.value.push(item); self.$emit("input", self.value); return; }).catch(notification.exception); return; }).catch(notification.exception); } } }, onCut(event) { const self = this; let id = event.data.id; for (let i = 0; i < self.value.length; i++) { if (self.value[i].id == id) { self.value.splice(i, 1); i--; break; // Just remove one } } // Do something to signal that this item has been removed this.$emit("input", this.value); ItemEventBus.$emit('coursechange'); }, relocateStudyItem(item) { const iteminfo = {'id': item.id, 'layer': this.layer, 'slot': this.slotindex, 'line_id': this.line.id}; return call([{ methodname: 'local_treestudyplan_reorder_studyitems', args: {'items': [iteminfo]} // Function was designed to relocate multiple items at once, hence the array }])[0].catch(notification.exception); }, onDragEnter(event) { this.hover.component = event.data; this.hover.type = event.type; }, onDragLeave() { this.hover.component = null; this.hover.type = null; }, maxSpan() { // Determine the maximum span for components in this slot return this.page.periods - this.slotindex + 1; }, makeType(item) { return { item: true, component: false, span: item.span, type: this.type, }; }, checkType(type) { if (type.type == this.type) { if (settings("hivizdropslots") && !type.item) { return false; } else { if (type == 'filter') { return true; } else if (type.span <= this.maxSpan()) { return true; } else { return false; } } } else { return false; } } }, template: `
{{plantext.drophere}}
`, }); Vue.component('t-item-timing-checker', { props: { value: { type: Object, // T-item model }, page: { type: Object, // Studyplanpage }, line: { type: Object, // Studyline }, period: { type: Object, // Studyplan data }, maxspan: { type: Number, }, hidden: { type: Boolean, 'default': false, } }, computed: { endperiod() { const endperiodnr = Math.min(this.page.periods, this.period.period + (this.value.span - 1)); return this.page.perioddesc[endperiodnr - 1]; }, coursePeriodMatches() { const self = this; if (self.page.timeless) { // Always return true in timeless mode. return true; } if (self.value && self.value.type == 'course') { self.datechanger.coursespan = datespaninfo(self.value.course.startdate, self.value.course.enddate); self.datechanger.periodspan = datespaninfo(self.period.startdate, self.endperiod.enddate); if (self.datechanger.coursespan.first.getTime() == self.datechanger.periodspan.first.getTime() && self.datechanger.coursespan.last.getTime() == self.datechanger.periodspan.last.getTime()) { return true; } else { return false; } } else { debug.warn("Timing thing not properly configured", self.value, self.period, self.maxspan); return false; } }, }, data() { return { // Create random id to avoid opening the wrong modals id: Math.floor(Math.random() * Date.now()).toString(16), text: strings.courseTiming, datechanger: { coursespan: null, periodspan: null, globals: { 'default': false, defaultchoice: false, hidewarn: false, }, } }; }, methods: { validateCoursePeriod() { const self = this; if (!self.page.timeless) { debug.info("Validating course and period"); if (!(self.coursePeriodMatches)) { debug.info("Course timing does not match period timing"); if (self.value.course.canupdatecourse) { if (!self.hidden || !self.datechanger.globals.default) { // Periods do not match, pop up the date change request this.$bvModal.show('t-course-timing-matching-' + this.id); } else if (self.datechanger.globals.defaultvalue) { // G for it without asking self.changeCoursePeriod(); } } else { // User is not able to change course timing - show a warning if (!self.hidden || !self.datechanger.globals.hidewarn) { this.$bvModal.show('t-course-timing-warning-' + this.id); } } } else { debug.info("Course timing matches period", self.datechanger); } } else { debug.info("Skipping course timing check because of timeless mode", self.datechanger); } }, changeCoursePeriod() { const self = this; // Save the state if (self.datechanger.globals.default) { self.datechanger.globals.defaultvalue = true; } return call([{ methodname: 'local_treestudyplan_course_period_timing', args: { 'period_id': self.period.id, 'course_id': this.value.course.id, span: this.value.span, } }])[0].catch(notification.exception).then((response) => { self.value.course.startdate = response.startdate; self.value.course.enddate = response.enddate; self.value.course.timing = response.timing; self.$emit("input", self.value); return; }); }, checkFilterSlotBusy(slotindex) { debug.info("checking filter", this.line.slots, slotindex, this.value.layer); if (this.line.slots[slotindex]) { const list = this.line.slots[slotindex].filters; for (const ix in list) { if (list[ix].layer == this.value.layer) { debug.info("Busy:", list[ix]); return list[ix]; } } } return null; }, nextFreeFilterLayer(slotindex) { const layer = this.value.layer; const list = this.line.slots[slotindex].filters; const usedLayers = []; for (const ix in list) { usedLayers.push(list[ix].layer); } let nextlyr = layer + 1; while (usedLayers.includes(nextlyr)) { nextlyr++; } return nextlyr; }, checkCourseSlotBusy(slotindex) { debug.info("checking ", this.line.slots, slotindex, this.value.layer); if (this.line.slots[slotindex]) { const list = this.line.slots[slotindex].courses; for (const ix in list) { if (list[ix].layer == this.value.layer) { debug.info("Busy:", list[ix]); return list[ix]; } } } return null; }, nextFreeCourseLayer(slotindex) { const layer = this.value.layer; const list = this.line.slots[slotindex].courses; const usedLayers = []; for (const ix in list) { usedLayers.push(list[ix].layer); } let nextlyr = layer + 1; while (usedLayers.includes(nextlyr)) { nextlyr++; } return nextlyr; }, shiftCollisions(span) { // Check all periods for collision const items = []; for (let i = this.value.slot; i < this.value.slot + span; i++) { const busyFilter = this.checkFilterSlotBusy(i); if (busyFilter) { const nextlyr = this.nextFreeFilterLayer(i); items.push({ id: busyFilter.id, layer: nextlyr, 'line_id': this.line.id, slot: busyFilter.slot, }); busyFilter.layer = nextlyr; } const busyCourse = this.checkCourseSlotBusy(i); if (busyCourse && busyCourse.id != this.value.id) { const nextlyr = this.nextFreeCourseLayer(i); items.push({ id: busyCourse.id, layer: nextlyr, 'line_id': this.line.id, slot: busyCourse.slot, }); busyCourse.layer = nextlyr; } } if (items.length > 0) { call([{ methodname: 'local_treestudyplan_reorder_studyitems', args: {items: items} }])[0].catch(notification.exception); } }, changeSpan(span) { const self = this; this.shiftCollisions(span); return call([{ methodname: 'local_treestudyplan_set_studyitem_span', args: { id: self.value.id, span: span } }])[0].catch(notification.exception).then((response) => { self.value.span = response.span; self.$emit('input', self.value); self.$nextTick(() => { self.validateCoursePeriod(); }); return; }); }, formatDuration(dsi) { let s = ""; if (dsi.years == 1) { s += `1 ${this.text.year}, `; } else if (dsi.years > 1) { s += `${dsi.years} ${this.text.years}, `; } if (dsi.weeks == 1) { s += `1 ${this.text.week}, `; } else if (dsi.weeks > 1) { s += `${dsi.weeks} ${this.text.weeks}, `; } if (dsi.days == 1) { s += `1 ${this.text.day}, `; } else if (dsi.days > 1) { s += `${dsi.days} ${this.text.days}, `; } return s.toLocaleLowerCase(); }, }, // To avoid the span creeping in the dom where it shouldn't, set display to none if it is hidden // This does not affect the modals, which are rendered outside of this element when needed template: `
{{ text.desc }}
{{ text.question }}

{{ text.course }}

{{ value.course.fullname }}

{{ value.course.shortname }}

{{ datechanger.coursespan.formatted.first}} - {{ datechanger.coursespan.formatted.last}}

{{ text.duration }}
{{ formatDuration(datechanger.coursespan)}}

{{ text.period }}

{{ period.fullname }} - {{ endperiod.fullname }}

{{ period.shortname }} - {{ endperiod.shortname }}

{{ datechanger.periodspan.formatted.first}} - {{ datechanger.periodspan.formatted.last}}

{{ text.duration }}
{{ formatDuration(datechanger.periodspan)}}

{{ text.rememberchoice }}
{{ text.desc }}
{{ text.warning }}

{{ text.course }}

{{ value.course.fullname }}

{{ value.course.shortname }}

{{ datechanger.coursespan.formatted.first}} - {{ datechanger.coursespan.formatted.last}}

{{ text.duration }}
{{ formatDuration(datechanger.coursespan)}}

"6">

{{ text.period }}

{{ period.fullname }} - {{ endperiod.fullname }}

{{ period.shortname }} - {{ endperiod.shortname }}

{{ datechanger.periodspan.formatted.first}} - {{ datechanger.periodspan.formatted.last}}

{{ text.duration }}
{{ formatDuration(datechanger.periodspan)}}

{{ text.hidewarning }}
`, }); Vue.component('t-item', { props: { value: { type: Object, default() { return null; }, }, dummy: { type: Boolean, default() { return false; }, }, plan: { type: Object, // Studyplan page default() { return null; }, }, line: { type: Object, // Studyplan page default() { return null; }, }, page: { type: Object, // Studyplan page default() { return null; }, }, period: { type: Object, // Studyplan page default() { return null; }, }, maxspan: { type: Number, default() { return 0; }, }, }, data() { return { dragLine: null, dragEventListener: null, deleteMode: false, conditionOptions: stringKeys.conditions, text: strings.itemText, showContext: false, lines: [], }; }, methods: { dragStart(event) { // Add line between start point and drag image this.deleteMode = false; let start = document.getElementById('studyitem-' + this.value.id); let dragelement = document.getElementById('t-item-cdrag-' + this.value.id); dragelement.style.position = 'fixed'; dragelement.style.left = event.position.x + 'px'; dragelement.style.top = event.position.y + 'px'; this.dragLine = new SimpleLine(start, dragelement, { color: "#777", gravity: { start: LINE_GRAVITY, end: LINE_GRAVITY, }, }); // Add separate event listener to reposition mouse move document.addEventListener("mousemove", this.onMouseMove); }, dragEnd() { if (this.dragLine !== null) { this.dragLine.remove(); } let dragelement = document.getElementById('t-item-cdrag-' + this.value.id); dragelement.style.removeProperty('left'); dragelement.style.removeProperty('top'); dragelement.style.removeProperty('position'); document.removeEventListener("mousemove", this.onMouseMove); }, onMouseMove: function(event) { let dragelement = document.getElementById('t-item-cdrag-' + this.value.id); dragelement.style.position = 'fixed'; dragelement.style.left = event.clientX + 'px'; dragelement.style.top = event.clientY + 'px'; // Line will follow automatically }, onDrop(event) { let fromid = event.data.id; let toid = this.value.id; this.redrawLines(); call([{ methodname: 'local_treestudyplan_connect_studyitems', args: {'from_id': fromid, 'to_id': toid} }])[0].then((response)=>{ let conn = {'id': response.id, 'from_id': response.from_id, 'to_id': response.to_id}; ItemEventBus.$emit("createdConnection", conn); this.value.connections.in.push(conn); return; }).catch(notification.exception); }, redrawLine(conn) { const lineColor = "var(--success)"; const start = document.getElementById(`studyitem-${conn.from_id}`); const end = document.getElementById(`studyitem-${conn.to_id}`); // Delete old line if (this.lines[conn.to_id]) { this.lines[conn.to_id].remove(); delete this.lines[conn.to_id]; } // Create a new line if the start and finish items are visible if (start !== null && end !== null && isVisible(start) && isVisible(end)) { this.lines[conn.to_id] = new SimpleLine(start, end, { color: lineColor, gravity: { start: LINE_GRAVITY, end: LINE_GRAVITY, }, }); } }, deleteLine(conn) { const self = this; call([{ methodname: 'local_treestudyplan_disconnect_studyitems', args: {'from_id': conn.from_id, 'to_id': conn.to_id} }])[0].then((response)=>{ if (response.success) { this.removeLine(conn); // Send disconnect event on message bus, so the connection on the other end can delete it too ItemEventBus.$emit("connectionDisconnected", conn); // Remove connection from our outgoing list let index = self.value.connections.out.indexOf(conn); self.value.connections.out.splice(index, 1); } return; }).catch(notification.exception); }, highlight(conn) { if (this.lines[conn.to_id]) { this.lines[conn.to_id].setConfig({color: "var(--danger)"}); } }, normalize(conn) { if (this.lines[conn.to_id]) { this.lines[conn.to_id].setConfig({color: "var(--success)"}); } }, updateItem() { call([{ methodname: 'local_treestudyplan_edit_studyitem', args: { 'id': this.value.id, 'conditions': this.value.conditions, 'continuation_id': this.value.continuation_id, } }])[0].catch(notification.exception); }, doShowContext(event) { if (this.hasContext) { this.showContext = true; event.preventDefault(); } }, redrawLines() { if (this.value.connections && this.value.connections.out) { for (let i in this.value.connections.out) { let conn = this.value.connections.out[i]; this.redrawLine(conn); } } }, // EVENT LISTENERS onCreatedConnection(conn) { if (conn.from_id == this.value.id) { this.value.connections.out.push(conn); this.redrawLine(conn); } }, // Listener for the signal that a connection was removed by the outgoing item onRemovedConnection(conn) { if (this.value.connections && this.value.connections.out) { for (let i in this.value.connections.in) { let cin = this.value.connections.in[i]; if (conn.id == cin.id) { self.value.connections.out.splice(i, 1); } } } }, // Listener for reposition events // When an item in the list is repositioned, all lines need to be redrawn onRePositioned() { this.redrawLines(); }, // When an item is disPositioned - (temporarily) removed from the list, // all connections need to be deleted. onDisPositioned(reid) { if (this.value.connections && this.value.connections.out) { for (let i in this.value.connections.out) { let conn = this.value.connections.out[i]; if (conn.to_id == reid) { this.removeLine(conn); } } } }, // When an item is deleted // all connections to/from that item need to be cleaned up onItemDeleted(itemid) { const self = this; if (this.value.connections && this.value.connections.out) { for (const i in this.value.connections.out) { let conn = this.value.connections.out[i]; if (conn.to_id == itemid) { self.removeLine(conn); self.value.connections.out.splice(i, 1); } } } if (this.value.connections && this.value.connections.in) { for (const i in this.value.connections.in) { let conn = this.value.connections.in[i]; if (conn.from_id == itemid) { self.value.connections.out.splice(i, 1); } } } }, onRedrawLines() { this.redrawLines(); }, removeLine(conn) { if (this.lines[conn.to_id]) { this.lines[conn.to_id].remove(); delete this.lines[conn.to_id]; } }, deleteItem() { const self = this; const msgparams = { item: this.text['type_' + this.value.type].toLocaleLowerCase(), name: (this.value.type == 'course') ? this.value.course.displayname : "", line: (this.line) ? this.line.name : "", period: (this.period) ? this.period.fullname : this.plan.name, }; this.$bvModal.msgBoxConfirm(strformat(this.text.item_delete_message, msgparams), { okVariant: 'danger', okTitle: this.text.ok, cancelTitle: this.text.cancel, }).then(value => { if (value) { // Confirmed to delete. call([{ methodname: 'local_treestudyplan_delete_studyitem', args: { 'id': self.value.id, } }])[0].then((response) => { if (response.success == true) { self.$emit("deleted", {data: self.value}); } return; }).catch(notification.exception); } return; }).catch(err => { debug.console.error(err); }); } }, computed: { hasConnectionsOut() { return !(["finish"].includes(this.value.type)); }, hasConnectionsIn() { return !(["start"].includes(this.value.type)); }, hasContext() { return ['start', 'junction', 'finish'].includes(this.value.type); } }, created() { // Add event listeners on the message bus // But only if not in "dummy" mode - mode which is used for droplist placeholders // Since an item is "fully made" with all references, not specifying dummy mode really messes things up if (!this.dummy) { // Listener for the signal that a new connection was made and needs to be drawn // Sent by the incoming item - By convention, outgoing items are responsible for drawing the lines ItemEventBus.$on('createdConnection', this.onCreatedConnection); // Listener for the signal that a connection was removed by the outgoing item ItemEventBus.$on('removedConnection', this.onRemovedConnection); // Listener for reposition events // When an item in the list is repositioned, all lines need to be redrawn ItemEventBus.$on('rePositioned', this.onRePositioned); // When an item is disPositioned - (temporarily) removed from the list, // all connections need to be deleted. ItemEventBus.$on('disPositioned', this.onDisPositioned); // When an item is deleted // all connections to/from that item need to be cleaned up ItemEventBus.$on('itemDeleted', this.onItemDeleted); ItemEventBus.$on('redrawLines', this.onRedrawLines); } }, mounted() { // Initialize connection lines when mounting // But only if not in "dummy" mode - mode which is used for droplist placeholders // Since an item is "fully made" with all references, not specifying dummy mode really messes things up if (!this.dummy) { this.redrawLines(); setTimeout(()=>{ ItemEventBus.$emit("rePositioned", this.value.id); }, 10); } }, beforeDestroy() { if (!this.dummy) { for (let i in this.value.connections.out) { let conn = this.value.connections.out[i]; this.removeLine(conn); } ItemEventBus.$emit("disPositioned", this.value.id); // Remove event listeners ItemEventBus.$off('createdConnection', this.onCreatedConnection); ItemEventBus.$off('removedConnection', this.onRemovedConnection); ItemEventBus.$off('rePositioned', this.onRePositioned); ItemEventBus.$off('disPositioned', this.onDisPositioned); ItemEventBus.$off('itemDeleted', this.onItemDeleted); ItemEventBus.$off('redrawLines', this.onRedrawLines); } }, updated() { if (!this.dummy) { this.redrawLines(); } }, template: ` `, }); Vue.component('t-item-invalid', { props: { 'value': { type: Object, default() { return null; }, }, }, data() { return { text: strings.invalid, }; }, methods: { }, template: `
{{text.error}}
`, }); // TAG: Course item Vue.component('t-item-course', { props: { value: { type: Object, default() { return null; }, }, plan: { type: Object, default() { return null; }, }, line: { type: Object, default() { return null; }, }, page: { type: Object, // PAge data default() { return null; } }, period: { type: Object, // Period data default() { return null; } }, maxspan: { type: Number, default() { return 0; } }, }, data() { return { conditionOptions: stringKeys.conditions, text: strings.itemCourseText, }; }, computed: { useItemConditions() { if (this.plan && this.plan.aggregation_info && this.plan.aggregation_info.useItemConditions !== undefined) { return this.plan.aggregation_info.useItemConditions; } else { return false; } }, configurationState() { if (this.hasGrades() || this.hasCompletions() || this.hasCompetencies()) { return "t-configured-ok"; } else { return "t-configured-alert"; } }, configurationIcon() { if (this.hasGrades() || this.hasCompletions() || this.hasCompetencies()) { return "check"; } else { return "exclamation-circle"; } }, startdate() { return formatDate(this.value.course.startdate); }, enddate() { if (this.value.course.enddate > 0) { return formatDate(this.value.course.enddate); } else { return this.text.noenddate; } }, wwwroot() { return Config.wwwroot; } }, methods: { hasGrades() { if (this.value.course.grades && this.value.course.grades.length > 0) { for (const g of this.value.course.grades) { if (g.selected) { return true; } } } return false; }, hasCompletions() { if (this.value.course.completion && this.value.course.completion.conditions) { for (const cgroup of this.value.course.completion.conditions) { if (cgroup.items && cgroup.items.length > 0) { return true; } } } return false; }, hasCompetencies() { if (this.value.course.competency && this.value.course.competency.competencies) { return (this.value.course.competency.competencies.length > 0); } return false; }, includeChanged(newValue, g) { call([{ methodname: 'local_treestudyplan_include_grade', args: { 'grade_id': g.id, 'item_id': this.value.id, 'include': newValue, 'required': g.required, } }])[0].catch(notification.exception); }, requiredChanged(newValue, g) { call([{ methodname: 'local_treestudyplan_include_grade', args: { 'grade_id': g.id, 'item_id': this.value.id, 'include': g.selected, 'required': newValue, } }])[0].catch(notification.exception); }, updateConditions() { call([{ methodname: 'local_treestudyplan_edit_studyitem', args: { 'id': this.value.id, 'conditions': this.value.conditions, } }])[0].catch(notification.exception); }, }, template: ` `, }); Vue.component('t-item-course-grades', { props: { 'value': { type: Object, default() { return null; }, }, 'plan': { type: Object, default() { return null; }, }, }, data() { return { conditionOptions: stringKeys.conditions, text: strings.itemCourseText, }; }, computed: { useRequiredGrades() { if (this.plan && this.plan.aggregation_info && this.plan.aggregation_info.useRequiredGrades !== undefined) { return this.plan.aggregation_info.useRequiredGrades; } else { return false; } }, selectedgrades() { let list = []; for (let ix in this.value.course.grades) { let g = this.value.course.grades[ix]; if (g.selected) { list.push(g); } } return list; }, }, methods: { includeChanged(newValue, g) { call([{ methodname: 'local_treestudyplan_include_grade', args: { 'grade_id': g.id, 'item_id': this.value.id, 'include': newValue, 'required': g.required, } }])[0].catch(notification.exception); }, requiredChanged(newValue, g) { call([{ methodname: 'local_treestudyplan_include_grade', args: { 'grade_id': g.id, 'item_id': this.value.id, 'include': g.selected, 'required': newValue, } }])[0].catch(notification.exception); }, }, template: `
  • {{text.grade_include}}{{text.grade_require}}
`, }); Vue.component('t-item-course-completion', { props: { value: { type: Object, default() { return {}; }, }, guestmode: { type: Boolean, 'default': false, }, course: { type: Object, default() { return {}; }, }, }, data() { return { text: strings.completion, }; }, computed: { hasCompletions() { if (this.value.conditions) { for (const cgroup of this.value.conditions) { if (cgroup.items && cgroup.items.length > 0) { return true; } } } return false; }, wwwroot() { return Config.wwwroot; } }, methods: { completionIcon(completion) { switch (completion) { case "progress": return "exclamation-circle"; case "complete": return "check-circle"; case "complete-pass": return "check-circle"; case "complete-fail": return "times-circle"; default: // Case "incomplete" return "circle-o"; } }, completionTag(cgroup) { return cgroup.completion ? 'completed' : 'incomplete'; } }, template: `
{{ text.aggregation_overall_all}}{{ text.aggregation_overall_any}}
{{text.completion_not_configured}}!
{{text.configure_completion}}
`, }); // TAG: Course competency Vue.component('t-item-course-competency', { props: { value: { type: Object, default() { return {}; }, }, guestmode: { type: Boolean, 'default': false, }, item: { type: Object, default() { return {id: null}; }, } }, data() { return { text: strings.competency, }; }, computed: { hasCompletions() { if (this.value.conditions) { for (const cgroup of this.value.conditions) { if (cgroup.items && cgroup.items.length > 0) { return true; } } } return false; }, wwwroot() { return Config.wwwroot; } }, methods: { pathtags(competency) { const path = competency.path; let s = ""; for (const ix in path) { const p = path[ix]; if (ix > 0) { s += " / "; } let url; if (p.type == 'competency') { url = `/admin/tool/lp/competencies.php?competencyid=${p.id}`; } else { url = `/admin/tool/lp/competencies.php?competencyframeworkid=${p.id}&pagecontextid=${p.contextid}`; } s += `${p.title}`; } return s; }, requiredChanged(newValue, c) { call([{ methodname: 'local_treestudyplan_require_competency', args: { 'competency_id': c.id, 'item_id': this.item.id, 'required': newValue, } }])[0].catch(notification.exception); }, }, template: `
{{text.competency_not_configured}}
{{text.configure_competency}}
`, }); /* ********************************** * * * Toolbox list components * * * ************************************/ Vue.component('t-item-junction', { props: { value: { type: Object, default() { return {}; }, }, }, data() { return { conditionOptions: stringKeys.conditions, }; }, methods: { }, template: `
`, }); Vue.component('t-item-finish', { props: { value: { type: Object, default() { return {}; }, }, }, data() { return { }; }, methods: { }, template: `
`, }); Vue.component('t-item-start', { props: { value: { type: Object, default() { return {}; }, }, }, data() { return {}; }, methods: { }, template: `
`, }); Vue.component('t-item-badge', { props: { value: { type: Object, default() { return { badge: {} }; }, }, }, data() { return { txt: strings, text: strings.itemText, }; }, methods: { }, template: `
{{value.badge.name}}

{{value.badge.description}}

{{ txt.badge.badgeinfo }}

`, }); Vue.component('t-coursecat-list', { props: { value: { type: Array, default() { return {}; }, }, }, data() { return { }; }, methods: { }, template: ` `, }); Vue.component('t-coursecat-list-item', { props: { value: { type: Object, default() { return {}; }, }, }, data() { return { loading: false, }; }, computed: { showSpinner() { return this.canLoadMore(); }, hasDetails() { return (this.value.haschildren || this.value.hascourses); } }, methods: { canLoadMore() { return (this.value.haschildren && (!this.value.children)) || (this.value.hascourses && (!this.value.courses)); }, onShowDetails() { const self = this; if (this.canLoadMore()) { call([{ methodname: 'local_treestudyplan_get_category', args: { "id": this.value.id, } }])[0].then((response) => { self.$emit('input', response); return; }).catch(notification.exception); } } }, template: `
  • {{ value.category.name }} {{ value.category.name }}
  • `, }); Vue.component('t-course-list', { props: { value: { type: Array, default() { return {}; }, }, }, data() { return { }; }, methods: { makeType() { return { item: false, component: true, span: 1, // TODO: Detect longer courses and give them an appropriate span type: 'gradable', }; }, }, template: ` `, }); Vue.component('t-toolbox', { props: { value: { type: Boolean, 'default': true, }, activepage: { type: Object, default() { return null; } }, coaching: { type: Boolean, 'default': false, }, studyplanid: { type: Number, 'default': 0, } }, data() { return { toolboxright: !(settings("toolboxleft")), text: strings.toolbox, relatedbadges: [], systembadges: [], courses: [], filters: { courses: "", systembadges: "", relatedbadges: "", }, loadingcourses: false, loadingcategories: [], badgelistshown: { relatedbadges: true, systembadges: false, } }; }, watch: { // Whenever activepage changes, this function will run activepage(/* Params newVal, oldVal */) { this.filterRelatedbadges(); } }, mounted() { const self = this; this.initialize(); this.$root.$on('bv::collapse::state', (collapseId, isJustShown) => { self.badgelistshown[collapseId] = !!isJustShown; }); }, computed: { filterComponentType() { return { item: false, component: true, span: 1, type: 'filter', }; }, filteredCourses() { const self = this; if (self.filters.courses) { return self.filterCategories(self.courses); } else { return self.courses; } } }, methods: { hivizdrop() { return settings("hivizdropslots"); }, filterCategories(catlist) { const self = this; const list = []; const search = new RegExp(`.*?${self.filters.courses}.*?`, "ig"); for (const cat of catlist) { const clone = Object.assign({}, cat); clone.courses = []; if (cat.courses) { for (const course of cat.courses) { if (search.test(course.shortname) || search.test(course.fullname)) { clone.courses.push(course); } } } else if (cat.hascourses && !(self.loadingcategories.includes(cat.id))) { self.loadingcategories.push(cat.id); debug.info(`Loading from category ${cat.category.name}`, cat); call([{ methodname: 'local_treestudyplan_get_category', args: { "id": cat.id } }])[0].then((response) => { // Add reactive array 'children' to cat self.$set(cat, "children", response.children); self.$set(cat, "courses", response.courses); self.loadingcategories.splice(self.loadingcategories.indexOf(cat.id), 1); return; }).catch(notification.exception); } if (cat.children) { clone.children = self.filterCategories(cat.children); } else if (cat.haschildren && !(self.loadingcategories.includes(cat.id))) { self.loadingcategories.push(cat.id); debug.info(`Loading from category ${cat.category.name}`, cat); call([{ methodname: 'local_treestudyplan_get_category', args: { "id": cat.id, } }])[0].then((response) => { // Add reactive array 'children' to cat self.$set(cat, "children", response.children); self.loadingcategories.splice(self.loadingcategories.indexOf(cat.id), 1); return; }).catch(notification.exception); } if ((clone.children && clone.children.length) || clone.courses.length) { list.push(clone); } } return list; }, initialize() { const self = this; self.loadingcourses = true; call([{ methodname: 'local_treestudyplan_map_categories', args: { 'studyplan_id': self.studyplanid, } }])[0].then((response) => { self.courses = response; self.loadingcourses = false; return; }).catch(notification.exception); this.filterSystembadges(); this.filterRelatedbadges(); }, filterSystembadges() { const self = this; call([{ methodname: 'local_treestudyplan_search_badges', args: { search: this.filters.systembadges || "", } }])[0].then((response) => { self.systembadges = response; return; }).catch(notification.exception); }, filterRelatedbadges() { const self = this; if (this.activepage) { call([{ methodname: 'local_treestudyplan_search_related_badges', args: { 'page_id': this.activepage.id, search: this.filters.relatedbadges || "" } }])[0].then((response) => { self.relatedbadges = response; return; }).catch(notification.exception); } }, resetSystembadges() { this.filters.systembadges = ""; this.filterSystembadges(); }, resetRelatedbadges() { this.filters.relatedbadges = ""; this.filterRelatedbadges(); }, }, template: `

    {{text.toolbox}}

    {{text.toolbarRight}}
     
    • {{ text.toolJunction }}
    • {{ text.toolFinish }}
    • {{ text.toolStart }}
     
    • {{b.name}}
     
    • {{b.name}}
    `, }); }, };