/*eslint no-var: "error"*/ /*eslint no-console: "off"*/ /*eslint no-unused-vars: warn */ /*eslint max-len: ["error", { "code": 160 }] */ /*eslint-disable no-trailing-spaces */ /*eslint-env es6*/ // Put this file in path/to/plugin/amd/src import {SimpleLine} from "./simpleline/simpleline"; import {call} from 'core/ajax'; import notification from 'core/notification'; import {get_strings} from 'core/str'; import {load_stringkeys, load_strings, strformat } from './util/string-helper'; import {format_date, add_days, datespaninfo } from './util/date-helper'; import {objCopy,transportItem} from './studyplan-processor'; import Debugger from './util/debugger'; import {download,upload} from './downloader'; import {ProcessStudyplan} from './studyplan-processor'; import TSComponents from './treestudyplan-components'; import mFormComponents from "./util/mform-helper"; const STUDYPLAN_EDITOR_FIELDS = ['name','shortname','description','idnumber','context_id', 'aggregation','aggregation_config']; const STUDYPLAN_EDITOR_PAGE_FIELDS = //TODO: Add 'fullname', 'shortname' and 'description' when implementing proper page management ['context_id', 'periods','startdate','enddate']; const PERIOD_EDITOR_FIELDS = ['fullname','shortname','startdate','enddate']; const LINE_GRAVITY = 1.3; const datechanger_globals = { default: false, defaultchoice: false, hidewarn: false, }; export default { STUDYPLAN_EDITOR_FIELDS: STUDYPLAN_EDITOR_FIELDS, // make copy available in plugin install(Vue/*,options*/){ Vue.use(TSComponents); Vue.use(mFormComponents); 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(); let string_keys = load_stringkeys({ conditions: [ { value: 'ALL', textkey: 'condition_all'}, { value: 'ANY', textkey: 'condition_any'}, ], }); let strings = load_strings({ studyplan_text: { studyline_editmode: 'studyline_editmode', toolbox_toggle: 'toolbox_toggle', editmode_modules_hidden:'editmode_modules_hidden', studyline_add: 'studyline_add', add$core: 'add$core', edit$core: 'edit$core', studyline_name: 'studyline_name', studyline_name_ph: 'studyline_name_ph', studyline_shortname: 'studyline_shortname', studyline_shortname_ph: 'studyline_shortname_ph', 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', }, studyplan_advanced: { advanced_tools: 'advanced_tools', confirm_cancel: 'confirm_cancel', confirm_ok: 'confirm_ok', success$core: 'success$core', error$core: 'failed$core', 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_force_scale_title: 'advanced_force_scale_title', advanced_force_scale_desc: 'advanced_force_scale_desc', advanced_force_scale_button: 'advanced_force_scale_button', advanced_disable_autoenddate_title: 'advanced_disable_autoenddate_title', advanced_disable_autoenddate_desc: 'advanced_disable_autoenddate_desc', advanced_disable_autoenddate_button: 'advanced_disable_autoenddate_button', advanced_confirm_header: 'advanced_confirm_header', advanced_force_scale_confirm: 'advanced_force_scale_confirm', advanced_import: 'advanced_import', advanced_export: 'advanced_export', advanced_export_csv: 'advanced_export_csv', advanced_import_from_file: 'advanced_import_from_file', advanced_purge: "advanced_purge", advanced_purge_expl: "advanced_purge_expl", advanced_cascade_cohortsync_title: "advanced_cascade_cohortsync_title", advanced_cascade_cohortsync_desc: "advanced_cascade_cohortsync_desc", advanced_cascade_cohortsync: "advanced_cascade_cohortsync", }, studyplan_edit: { studyplan_edit: 'studyplan_edit', studyplan_add: 'studyplan_add', }, period_edit: { edit: 'period_edit', fullname: 'studyplan_name', shortname: 'studyplan_shortname', startdate: 'studyplan_startdate', enddate: 'studyplan_enddate', }, course_timing: { 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', }, studyplan_associate: { associations: 'associations', associated_cohorts: 'associated_cohorts', associated_users: 'associated_users', associate_cohorts: 'associate_cohorts', associate_users: 'associate_users', add_association: 'add_association', delete_association: 'delete_association', associations_empty: 'associations_empty', associations_search: 'associations_search', cohorts: 'cohorts', users: 'users', selected: 'selected', name: 'name', context: 'context', }, item_text: { 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", }, item_course_text: { 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", }, 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", }, badge: { share_badge: "share_badge", dateissued: "dateissued", dateexpire: "dateexpire", badgeinfo: "badgeinfo", }, }); /* * T-STUDYPLAN-ADVANCED */ Vue.component('t-studyplan-advanced', { props: { value: { type: Object, default(){ return null;}, }, }, data() { return { force_scales: { selected_scale: null, result: [], }, text: strings.studyplan_advanced, }; }, created() { }, mounted() { }, updated() { }, computed: { scales(){ return [{ id: null, disabled: true, name: this.text.advanced_pick_scale, }].concat(this.value.advanced.force_scales.scales); }, }, methods: { disable_autoenddate(){ const self=this; call([{ methodname: 'local_treestudyplan_disable_autoenddate', args: { studyplan_id: this.value.id, } }])[0].done(function(response){ self.$bvModal.msgBoxConfirm((response.success?self.text.success$core:self.text.error$core) + "\n" + response.msg); }).fail(notification.exception); }, force_scales_start(){ // 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.force_scales.selected_scale, } }])[0].done(function(response){ self.force_scales.result = response; }).fail(notification.exception); } }); }, export_plan(format){ const self = this; if(format == undefined || !["json","csv"].includes(format)){ format = "json"; } call([{ methodname: 'local_treestudyplan_export_plan', args: { studyplan_id: this.value.id, format: format, }, }])[0].done(function(response){ download(self.value.shortname+"."+format,response.content,response.format); }).fail(notification.exception); }, import_studylines(){ //const self = this; upload((filename,content)=>{ call([{ methodname: 'local_treestudyplan_import_studylines', args: { studyplan_id: this.value.id, content: content, format: "application/json", }, }])[0].done(function(response){ if(response.success){ location.reload(); } else { debug.error("Import failed: ",response.msg); } }).fail(notification.exception); }, "application/json"); }, purge_studyline(){ call([{ methodname: 'local_treestudyplan_delete_studyplan', args: { id: this.value.id, force: true, }, }])[0].done(function(response){ if(response.success){ location.reload(); } else { debug.error("Could not delete plan: ",response.msg); } }).fail(notification.exception); }, cascade_cohortsync(){ const self = this; call([{ methodname: 'local_treestudyplan_cascade_cohortsync', args: { studyplan_id: this.value.id, }, }])[0].done(function(response){ self.$bvModal.msgBoxOk(response.success?self.text.success$core:self.text.error$core, { title: self.text.advanced_cascade_cohortsync}); }).fail(notification.exception); }, modal_close(){ this.force_scales.result = []; } }, template: ` {{text.advanced_tools}} {{ text.advanced_warning}}

{{ text.advanced_cascade_cohortsync_title}}

{{ text.advanced_cascade_cohortsync_desc}}
{{ text.advanced_cascade_cohortsync}}

{{ text.advanced_force_scale_title}}

{{ text.advanced_force_scale_desc}}
{{ text.advanced_force_scale_button}}
  • {{c.course.fullname}}
    • {{text.advanced_converted}}{{text.advanced_skipped}} {{text.advanced_error}}

{{ text.advanced_disable_autoenddate_title}}

{{ text.advanced_disable_autoenddate_desc}}
{{ text.advanced_disable_autoenddate_button}}
{{ text.advanced_export}} {{ text.advanced_import}} {{ text.advanced_export_csv}}

{{text.advanced_purge_expl}}

{{ text.advanced_purge}}

` }); /* * 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.studyplan_edit, }; }, 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 moved_from = self.value.context_id; const moved_to = updatedplan.context_id; const moved = (moved_from != moved_to); 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].done(function(response){ self.value = ProcessStudyplan(response,true); debug.info('studyplan processed'); self.$emit('input',self.value); }).fail(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, moved_from, moved_to); } } } }, } , 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: [], }, loading: { cohorts: false, users: false, }, search: {users: [], cohorts:[]}, selected: { search: {users: [] , cohorts:[]}, associated: {users: [] , cohorts:[]} }, text: strings.studyplan_associate, }; }, created() { }, mounted() { }, updated() { }, 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].done(function(response){ self.association.users = response.map(self.userOptionModel); self.loading.users = false; }).fail(notification.exception); call([{ methodname: 'local_treestudyplan_associated_cohorts', args: { studyplan_id: self.value.id,} }])[0].done(function(response){ self.association.cohorts = response.map(self.cohortOptionModel); self.loading.cohorts = false; }).fail(notification.exception); }, searchCohorts(searchtext){ const self = this; if(searchtext.length > 0) { call([{ methodname: 'local_treestudyplan_list_cohort', args: { like: searchtext, exclude_id: self.value.id} }])[0].done(function(response){ self.search.cohorts = response.map(self.cohortOptionModel); }).fail(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]; requests.push({ methodname: 'local_treestudyplan_connect_cohort', args: {studyplan_id: self.value.id, cohort_id: r}, fail: notification.exception, done: function(response){ if(response.success){ transportItem(associated,search,r); } } }); } call(requests); }, cohortDisassociate(){ const self = this; let requests = []; 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]; requests.push({ methodname: 'local_treestudyplan_disconnect_cohort', args: {studyplan_id: self.value.id, cohort_id: r}, fail: notification.exception, done: function(response){ if(response.success){ transportItem(search,associated,r); } } }); } call(requests); }, searchUsers(searchtext){ const self = this; if(searchtext.length > 0) { call([{ methodname: 'local_treestudyplan_find_user', args: { like: searchtext, exclude_id: self.value.id} }])[0].done(function(response){ self.search.users = response.map(self.userOptionModel); }).fail(notification.exception); } else { self.search.users = []; } }, userAssociate(){ const self = this; let requests = []; 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]; requests.push({ methodname: 'local_treestudyplan_connect_user', args: {studyplan_id: self.value.id, user_id: r}, fail: notification.exception, done: function(response){ if(response.success){ transportItem(associated,search,r); } } }); } call(requests); }, userDisassociate(){ const self = this; let requests = []; 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]; requests.push({ methodname: 'local_treestudyplan_disconnect_user', args: {studyplan_id: self.value.id, user_id: r}, fail: notification.exception, done: function(response){ if(response.success){ transportItem(search,associated,r); } } }); } call(requests); }, } , 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}} ` }); /******************* * * 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.period_edit, }; }, created() { }, mounted() { }, updated() { }, computed: { }, 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].done(function(response){ objCopy(self.value,response,PERIOD_EDITOR_FIELDS); self.$emit('input',self.value); self.$emit('edited',self.value); }).fail(notification.exception); }, refresh(){ const self = this; call([{ methodname: 'local_treestudyplan_get_period', args: { 'id': this.value.id }, }])[0].done(function(response){ objCopy(self.value,response,PERIOD_EDITOR_FIELDS); self.$emit('input',self.value); }).fail(notification.exception); }, add_day(date,days) { if( days === undefined ){ days = 1; } return add_days(date,days); }, sub_day(date,days) { if( days === undefined ){ days = 1; } return add_days(date,0 - days); }, } , template: ` {{ text.fullname}} {{ text.shortname}} {{ text.studyplan_startdate}} {{ text.studyplan_enddate}} ` }); /* * T-STUDYPLAN */ Vue.component('t-studyplan', { props: [ 'value', 'index', ], 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', }, }, edit: { toolbox_shown: false, studyline: { editmode: false, data: { name: '', shortname: '', color: '#DDDDDD', }, original: {}, }, studyplan: { data: { name: '', shortname: '', description: '', slots : 4, startdate: '2020-08-01', enddate: '', aggregation: '', aggregation_config: '', aggregation_info: { useRequiredGrades: true, useItemCondition: false, }, }, original: {}, } }, text: strings.studyplan_text, cache: { linelayers: {}, } }; }, created() { }, mounted() { if(this.page.studylines.length == 0){ // start in editmode if studylines are empty this.edit.studyline.editmode = true; } this.$root.$emit('redrawLines'); }, updated() { this.$root.$emit('redrawLines'); ItemEventBus.$emit('redrawLines'); }, computed: { columns() { return 1+ (this.page.periods * 2); }, columns_stylerule() { // 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 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, } }])[0].done(function(response){ page.studylines.push(response); newlineinfo.name = ''; newlineinfo.shortname = ''; newlineinfo.color = "#dddddd"; }).fail(notification.exception); }, editLineStart(line) { Object.assign(this.edit.studyline.data,line); this.edit.studyline.original = line; this.$bvModal.show('modal-edit-studyline-'+this.value.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,} }])[0].done(function(response){ originalline['name'] = response['name']; originalline['shortname'] = response['shortname']; originalline['color'] = response['color']; }).fail(notification.exception); }, deleteLine(page,line) { const self=this; get_strings([ {key: 'studyline_confirm_remove', param: line.name, component: 'local_treestudyplan' }, {key: 'delete', component: 'core' }, ]).then(function(s){ self.$bvModal.msgBoxConfirm(s[0], { okTitle: s[1], okVariant: 'danger', }).then(function(modalresponse){ if(modalresponse){ call([{ methodname: 'local_treestudyplan_delete_studyline', args: { 'id': line.id, } }])[0].done(function(response){ if(response.success == true){ let index = page.studylines.indexOf(line); page.studylines.splice(index, 1); } }).fail(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].done(function(response){ }).fail(notification.exception); }, deletePlan(studyplan){ const self=this; get_strings([ {key: 'studyplan_confirm_remove', param: studyplan.name, component: 'local_treestudyplan' }, {key: 'delete', component: 'core' }, ]).then(function(s){ self.$bvModal.msgBoxConfirm(s[0], { okTitle: s[1], okVariant: 'danger', }).then(function(modalresponse){ if(modalresponse){ call([{ methodname: 'local_treestudyplan_delete_studyplan', args: { 'id': studyplan.id, force: true} }])[0].done(function(response){ if(response.success == true){ self.$root.$emit("studyplanRemoved",studyplan); } }).fail(notification.exception); } }); }); }, deleteStudyItem(event){ //const self = this; let item = event.data; call([{ methodname: 'local_treestudyplan_delete_studyitem', args: { 'id': item.id, } }])[0].done(function(response){ if(response.success == true){ event.source.$emit('cut',event); } }).fail(notification.exception); }, showslot(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 = this.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){ // Really wish that 'for of' would work with the minifier moodle uses 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)][0]; if(prev) { prev.refresh(); } const next = this.$refs["periodeditor-" + (pi.period + 1)][0]; if(next) { next.refresh(); } }, add_day(date,days) { if( days === undefined ){ days = 1; } return add_days(date,days); }, sub_day(date,days) { if( days === undefined ){ days = 1; } return add_days(date,0 - days); }, toolbox_switched(event){ this.$emit('toggletoolbox',event); } } , template: `
{{ text.studyline_editmode }} {{ text.toolbox_toggle}}
{{text.associations}} {{text.edit$core}}
{{text.studyline_name}} {{text.studyline_shortname}} {{text.studyline_color}} {{ text.studyline_name}} {{ text.studyline_shortname}} {{ text.studyline_color}}
` }); /* * T-STUDYLINE-HEADER */ Vue.component('t-studyline-heading', { props: { value : { type: Object, // Studyline default: function(){ 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,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) => { // 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: function(){ 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; }, dragacceptlist(){ if(this.type == "gradable"){ return ["course", "gradable-item"]; } else { return ["filter", "filter-item"]; } }, courseHoverDummy(){ return {course: this.hover.component}; }, current(){ if( this.period && this.period.startdate && this.period.enddate){ const now = new Date(); const pstart = new Date(this.period.startdate); const pend = new Date(this.period.enddate); return (now >= pstart && now < pend); } else { return false; } }, spanCss(){ if(this.item && this.item.span > 1){ const span = (2 * this.item.span) - 1; return `width: 100%; grid-column: span ${span};`; } else { return ""; } } }, data() { return { text: strings.course_timing, resizeListener: null, hover: { component:null, type: null, }, datechanger: { coursespan: null, periodspan: null, default: false, defaultchoice: false, hidewarn: false, } }; }, methods: { 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).done(()=>{ if(this.$refs.timingChecker){ this.$refs.timingChecker.validate_course_period(); } }); } 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].done((response) => { let item = response; self.relocateStudyItem(item).done(()=>{ 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.validate_course_period(); } }); }); }).fail(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].done((response) => { let item = response; self.relocateStudyItem(item).done(()=>{ item.layer = this.layer; self.value.push(item); self.$emit("input",self.value); }); }).fail(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); }, 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].fail(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 // Used for setting the max in the timing adjustment screen(s) // And for checking the filter types // Assume this slot is first one available let freeIndex = this.slotindex; // Determine last free slot following this one in the layer for(let i = this.slotindex + 1; i <= this.page.periods; i++){ if(this.line.slots && this.line.slots[i] && this.line.slots[i].courses){ const clist = this.line.slots[i].courses; const flist = this.line.slots[i-1].filters; // next filter is in the same slot let busy = false; for(const ix in clist) { if( clist[ix].layer == this.layer) { busy = true; // slot is busy in this layer. break; } } for(const ix in flist) { if( flist[ix].layer == this.layer) { busy = true; // slot is busy in this layer. break; } } if(! busy) { // slot is free in this layer freeIndex = i; } else { break; // stop checking next slots } } else { break; // stop processing } } // calculate span from that return freeIndex - this.slotindex + 1; }, makeType(item){ return { item: true, component: false, span: item.span, type: this.type, }; }, checkType(type) { if(type.type == this.type){ if(type == 'filter'){ return true; } else if(type.span <= this.maxSpan()){ return true; } else { return false; } } else { return false; } } }, template: `
`, }); Vue.component('t-item-timing-checker', { props: { value: { type: Object, // t-item model }, page: { type: Object, // Studyplan data }, 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]; }, course_period_matches() { const self=this; 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 proper 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.course_timing, datechanger: { coursespan: null, periodspan: null, globals: datechanger_globals, } }; }, methods: { validate_course_period(force) { const self = this; debug.info("Validating course and period"); if(!(self.course_period_matches)){ 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){ // go for it without asking self.change_course_period(); } } 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); } }, change_course_period() { 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].fail(notification.exception).done((response) => { self.value.course.startdate = response.startdate; self.value.course.enddate = response.enddate; self.value.course.timing = response.timing; self.$emit("input",self.value); }); }, change_span(span) { const self=this; return call([{ methodname: 'local_treestudyplan_set_studyitem_span', args: { id: self.value.id, span: span } }])[0].fail(notification.exception).done((response) => { self.value.span = response.span; self.$emit('input',self.value); self.$nextTick(() => { self.validate_course_period(); }); } ); }, format_duration(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 }}
{{ format_duration(datechanger.coursespan)}}

{{ text.period }}

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

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

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

{{ text.duration }}
{{ format_duration(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 }}
{{ format_duration(datechanger.coursespan)}}

"6">

{{ text.period }}

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

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

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

{{ text.duration }}
{{ format_duration(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, condition_options: string_keys.conditions, text: strings.item_text, 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 from_id = event.data.id; let to_id = this.value.id; this.redrawLines(); call([{ methodname: 'local_treestudyplan_connect_studyitems', args: { 'from_id': from_id, 'to_id': to_id } }])[0].done((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); }).fail(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].done((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); } }).fail(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].fail(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 c_in = this.value.connections.in[i]; if(conn.id == c_in.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(){ 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); } } }, // When an item is disPositioned - (temporarily) removed from the list, // all connections need to be deleted. onDisPositioned(re_id){ 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 == re_id){ this.removeLine(conn); } } } }, // When an item is deleted // all connections to/from that item need to be cleaned up onItemDeleted(item_id){ 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 == item_id){ 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 == item_id){ 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].done(function(response){ if(response.success == true){ self.$emit("deleted",{ data: self.value }); } }).fail(notification.exception); } }).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); } }, beforeUpdate(){ }, updated(){ if(!this.dummy) { this.redrawLines(); } }, template: `
`, }); Vue.component('t-item-invalid', { props: { 'value' :{ type: Object, default: function(){ return null;}, }, }, data() { return { text: strings.invalid, }; }, methods: { }, template: `
{{text.error}}
`, }); Vue.component('t-item-course', { props: { value:{ type: Object, default(){ return null;}, }, plan:{ 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 { condition_options: string_keys.conditions, text: strings.item_course_text, }; }, 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()) { return "t-configured-ok"; } else { return "t-configured-alert"; } }, configurationIcon(){ if(this.hasGrades() || this.hasCompletions()) { return "check"; } else { return "exclamation-circle"; } }, startdate(){ return format_date(this.value.course.startdate); }, enddate(){ if(this.value.course.enddate){ return format_date(this.value.course.enddate); } else { return this.text.noenddate; } } }, 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; }, 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].fail(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].fail(notification.exception); }, updateConditions() { call([{ methodname: 'local_treestudyplan_edit_studyitem', args: { 'id': this.value.id, 'conditions': this.value.conditions, } }])[0].fail(notification.exception); }, }, created() { }, template: `
`, }); Vue.component('t-item-course-grades', { props: { 'value' :{ type: Object, default(){ return null;}, }, 'plan' :{ type: Object, default(){ return null;}, }, }, data() { return { condition_options: string_keys.conditions, text: strings.item_course_text, }; }, 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].fail(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].fail(notification.exception); }, }, created() { }, template: `
  • {{text.grade_include}}{{text.grade_require}}
`, }); Vue.component('t-item-course-completion',{ props: { value : { type: Object, default: function(){ return {};}, }, guestmode: { type: Boolean, default: false, }, course: { type: Object, default: function(){ return {};}, }, }, data() { return { text: strings.completion, }; }, created(){ const self = this; // Get text strings for condition settings let stringkeys = []; for(const key in this.text){ stringkeys.push({ key: key, component: 'local_treestudyplan'}); } get_strings(stringkeys).then(function(strings){ let i = 0; for(const key in self.text){ self.text[key] = strings[i]; i++; } }); }, computed: { hasCompletions() { if(this.value.conditions) { for(const cgroup of this.value.conditions){ if(cgroup.items && cgroup.items.length > 0){ return true; } } } return false; }, }, methods: { completion_icon(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"; } }, completion_tag(cgroup){ return cgroup.completion?'completed':'incomplete'; } }, template: `
{{ text.aggregation_overall_all}}{{ text.aggregation_overall_any}}
{{text.completion_not_configured}}!
{{text.configure_completion}}
`, }); /************************************ * * * Toolbox list components * * * ************************************/ Vue.component('t-item-junction',{ props: { value : { type: Object, default: function(){ return {};}, }, }, data() { return { condition_options: string_keys.conditions, }; }, methods: { }, template: `
`, }); Vue.component('t-item-finish',{ props: { value : { type: Object, default: function(){ return {};}, }, }, data() { return { }; }, methods: { }, template: `
`, }); Vue.component('t-item-start',{ props: { value : { type: Object, default: function(){ return {};}, }, }, data() { return { }; }, created(){ }, methods: { }, template: `
`, }); Vue.component('t-item-badge',{ props: { value : { type: Object, default: function(){ return { badge: {}};}, }, }, data() { return { txt: strings, text: strings.item_text, }; }, methods: { }, template: `
{{value.badge.name}}

{{value.badge.description}}

{{ txt.badge.badgeinfo }}

`, }); Vue.component('t-coursecat-list',{ props: { value : { type: Array, default: function(){ return {};}, }, }, data() { return { }; }, methods: { }, template: ` `, }); Vue.component('t-coursecat-list-item',{ props: { value : { type: Object, default: function(){ 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.children.length == 0)) || (this.value.hascourses && (!this.value.courses || this.value.courses.length == 0)); }, onShowDetails(){ const self = this; if(this.canLoadMore()) { call([{ methodname: 'local_treestudyplan_get_category', args: { "id": this.value.id} }])[0].done(function(response){ self.$emit('input', response); }).fail(notification.exception); } } }, template: `
  • {{ value.category.name }} {{ value.category.name }}
  • `, }); Vue.component('t-course-list',{ props: { value : { type: Array, default: function(){ 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: ` `, }); }, };