Multi-period courses drag and drop

This commit is contained in:
PMKuipers 2023-08-04 11:54:16 +02:00
parent f97c4cb2ef
commit 235972e2f4
6 changed files with 151 additions and 155 deletions

View file

@ -171,7 +171,15 @@ export function init(contextid,categoryid) {
}, },
contextid(){ contextid(){
return contextid; return contextid;
} },
filterComponentType(){
return {
item: true,
component: false,
span: 1,
type: 'filter',
};
},
}, },
methods: { methods: {
closeStudyplan() { closeStudyplan() {
@ -250,7 +258,6 @@ export function init(contextid,categoryid) {
download(plan.shortname+".json",response.content,response.format); download(plan.shortname+".json",response.content,response.format);
}).fail(notification.exception); }).fail(notification.exception);
}, },
}, },
}); });
} }

View file

@ -169,7 +169,6 @@ export default {
day: 'day$core', day: 'day$core',
rememberchoice: 'course_timing_rememberchoice', rememberchoice: 'course_timing_rememberchoice',
hidewarning: 'course_timing_hidewarning,' hidewarning: 'course_timing_hidewarning,'
}, },
studyplan_associate: { studyplan_associate: {
associations: 'associations', associations: 'associations',
@ -1285,7 +1284,7 @@ export default {
methods: { methods: {
countLineLayers(line){ countLineLayers(line){
let maxLayer = -1; let maxLayer = -1;
for(let i = 0; i <= this.value.slots; i++){ for(let i = 0; i <= this.page.periods; i++){
const slot = line.slots[i]; const slot = line.slots[i];
// Determine the amount of used layers in a studyline slit // Determine the amount of used layers in a studyline slit
for(const ix in line.slots[i].competencies){ for(const ix in line.slots[i].competencies){
@ -1447,6 +1446,34 @@ export default {
}).fail(notification.exception); }).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].competencies){
const list = line.slots[index-i].competencies;
for(const ix in list){ // Really wish that for ( .. of .. ) would work with the minifier
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;
}
} }
, ,
template: template:
@ -1540,35 +1567,36 @@ export default {
<!-- Line by line add the items --> <!-- Line by line add the items -->
<!-- The grid layout handles putting it in rows and columns --> <!-- The grid layout handles putting it in rows and columns -->
<template v-for="(line,lineindex) in page.studylines" <template v-for="(line,lineindex) in page.studylines"
><template v-for="layeridx in countLineLayers(line)+1" ><template v-for="(layernr,layeridx) in countLineLayers(line)+1"
><template v-for="(n,index) in (page.periods+1)" ><template v-for="(n,index) in (page.periods+1)"
> >
<t-studyline-slot <t-studyline-slot
v-if="index > 0" v-if="index > 0 && showslot(line, index, layeridx, 'gradable')"
type='gradable' type='gradable'
v-model="line.slots[index].competencies" v-model="line.slots[index].competencies"
:key="'c-'+lineindex+'-'+index+'-'+layeridx" :key="'c-'+lineindex+'-'+index+'-'+layernr"
:slotindex="index" :slotindex="index"
:line="line" :line="line"
:plan="value" :plan="value"
:page="page" :page="page"
:period="page.perioddesc[index-1]" :period="page.perioddesc[index-1]"
:layer="layeridx-1" :layer="layeridx"
:class="'t-studyline ' + ((lineindex%2==0)?' odd ':' even ') :class="'t-studyline ' + ((lineindex%2==0)?' odd ':' even ')
+ ((lineindex==0 && layeridx==1)?' first ':' ') + ((lineindex==0 && layernr==1)?' first ':' ')
+ ((lineindex==page.studylines.length-1)?' last ':' ')" + ((lineindex==page.studylines.length-1)?' last ':' ')"
></t-studyline-slot ></t-studyline-slot
><t-studyline-slot ><t-studyline-slot
type='filter' type='filter'
v-if="showslot(line, index, layeridx, 'filter')"
v-model="line.slots[index].filters" v-model="line.slots[index].filters"
:key="'f-'+lineindex+'-'+index+'-'+layeridx" :key="'f-'+lineindex+'-'+index+'-'+layernr"
:slotindex="index" :slotindex="index"
:line="line" :line="line"
:plan="value" :plan="value"
:page="page" :page="page"
:layer="layeridx-1" :layer="layeridx"
:class="'t-studyline ' + ((lineindex%2==0)?' odd ':' even ') :class="'t-studyline ' + ((lineindex%2==0)?' odd ':' even ')
+ ((lineindex==0 && layeridx==1)?' first ':'') + ((lineindex==0 && layernr==1)?' first ':'')
+ ((lineindex==page.studylines.length-1)?' last ':' ') + ((lineindex==page.studylines.length-1)?' last ':' ')
+ ((index==page.periods)?' rightmost':'')" + ((index==page.periods)?' rightmost':'')"
> >
@ -1876,6 +1904,14 @@ export default {
else { else {
return false; 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() { data() {
@ -1896,40 +1932,33 @@ export default {
}; };
}, },
methods: { methods: {
dragacceptitem(){
if(this.type == "gradable"){
return ["gradable-item"];
} else {
return ["filter-item"];
}
},
dragacceptcomponent(){
if(this.type == "gradable"){
return ["course",];
} else {
return ["filter",];
}
},
onDrop(event) { onDrop(event) {
this.hover.component = null; this.hover.component = null;
this.hover.type = null; this.hover.type = null;
const self = this; const self = this;
if(self.dragacceptitem().includes(event.type)) { if(event.type.item) {
let item = event.data; let item = event.data;
// Perform layer update - set this slot and layer here // To avoid weird visuals with the lines,
self.relocateStudyItem(item).done(()=>{ // we add the item to the proper place in the front-end first
item.layer = this.layer; item.layer = this.layer;
item.slot = this.slotindex; item.slot = this.slotindex;
self.value.push(item); self.value.push(item);
self.$emit("input",self.value); self.$emit("input",self.value);
self.validate_course_period();
// 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.
this.$nextTick(() => {
self.relocateStudyItem(item).done(()=>{
self.validate_course_period();
});
}); });
} }
else if(self.dragacceptcomponent().includes(event.type) ){ else if(event.type.component){
if(event.type == "course"){ if(event.type.type == "course"){
call([{ call([{
methodname: 'local_treestudyplan_add_studyitem', methodname: 'local_treestudyplan_add_studyitem',
args: { args: {
@ -1951,11 +1980,13 @@ export default {
self.relocateStudyItem(item).done(()=>{ self.relocateStudyItem(item).done(()=>{
self.value.push(item); self.value.push(item);
self.$emit("input",self.value); self.$emit("input",self.value);
self.validate_course_period(); // call the validate period function on next tick,
// since it paints the item in the slot first
this.$nextTick(self.validate_course_period);
}); });
}).fail(notification.exception); }).fail(notification.exception);
} }
else if(event.type == "filter") { else if(event.type.type == "filter") {
call([{ call([{
methodname: 'local_treestudyplan_add_studyitem', methodname: 'local_treestudyplan_add_studyitem',
args: { args: {
@ -2055,39 +2086,86 @@ export default {
else if(dsi.days > 1){ s += `${dsi.days} ${this.text.days}, `;} else if(dsi.days > 1){ s += `${dsi.days} ${this.text.days}, `;}
return s.toLocaleLowerCase(); return s.toLocaleLowerCase();
},
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].competencies){
const l = this.line.slots[i].competencies;
if(l[this.layer]) {
// slot is busy in this layer.
break;
} else {
freeIndex = i;
}
} 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: ` template: `
<div :class="'t-studyline-slot '+type + ' t-studyline-slot-'+slotindex + ' ' + ((slotindex==0)?' t-studyline-firstcolumn ':' ') <div :class="'t-studyline-slot '+type + ' t-studyline-slot-'+slotindex + ' ' + ((slotindex==0)?' t-studyline-firstcolumn ':' ')
+ (current?'current ':' ')" + (current?'current ':' ')"
:data-studyline="line.id" ref="main" :data-studyline="line.id" ref="main"
:style='spanCss'
><drag v-if="item" ><drag v-if="item"
:key="item.id" :key="item.id"
class="t-slot-item" class="t-slot-item"
:data="item" :data="item"
:type="type+'-item'" :type="makeType(item)"
@cut="onCut" @cut="onCut"
><t-item v-model="item" :plan="plan"></t-item ><t-item v-model="item" :plan="plan"></t-item
></drag ></drag
><drop v-else ><drop v-else
:class="'t-slot-drop '+type + (layer > 0?' secondary':' primary')" :class="'t-slot-drop '+type + (layer > 0?' secondary':' primary')"
:accepts-type="dragacceptlist" :accepts-type="checkType"
@drop="onDrop" @drop="onDrop"
mode="cut" mode="cut"
@dragenter="onDragEnter" @dragenter="onDragEnter"
@dragleave="onDragLeave" @dragleave="onDragLeave"
><template v-if="hover.component"> ><template v-if="hover.component">
<div v-if="hover.type == listtype+'-item'" <div v-if="hover.type.item"
class="t-slot-item feedback" class="t-slot-item feedback"
:key="hover.component.id" :key="hover.component.id"
><t-item v-model="hover.component" dummy></t-item ><t-item v-model="hover.component" dummy></t-item
></div ></div
><div v-else-if="hover.type == 'course'" ><div v-else-if="hover.type.type == 'gradable'"
class="t-slot-item feedback" class="t-slot-item feedback"
:key="'course-'+hover.component.id" :key="'course-'+hover.component.id"
><t-item-course v-model="courseHoverDummy"></t-item-course></div ><t-item-course v-model="courseHoverDummy"></t-item-course></div
><div v-else-if="hover.type == 'filter'" ><div v-else-if="hover.type.type == 'filter'"
class="t-slot-item feedback" class="t-slot-item feedback"
key="tooldrop" key="tooldrop"
><t-item-junction v-if="hover.component.type == 'junction'" ></t-item-junction ><t-item-junction v-if="hover.component.type == 'junction'" ></t-item-junction
@ -2941,110 +3019,9 @@ export default {
/************************************ /************************************
* * * *
* Competency map Vue components * * Toolbox list components *
* * * *
************************************/ ************************************/
Vue.component('t-competency-heading', {
props: {
value : {
type: Object,
},
},
data() {
return {
};
},
computed: {
inuse() {
return (this.value.inuse !== undefined && !!this.value.inuse);
}
},
methods: {
onCut(){
// console.info('cutevent-competency',event);
this.value.inuse=true;
this.$emit('input',this.value);
},
},
template: `
<span class="t-competency-heading">
<i :class="'t-'+value.type+' fa fa-puzzle-piece'" v-if="value.type == 'module'"></i>
<i :class="'t-'+value.type+' fa fa-map'" v-else-if="value.type == 'category'"></i>
<i :class="'t-'+value.type+' fa fa-check-square'" v-else-if="value.type == 'goal'"></i>
<i :class="'t-'+value.type+' fa fa-circle'" v-else ></i>
<drag
class="draggable-competency"
v-if="value.type == 'module' && !inuse"
:data="value"
type="competency"
@cut="onCut">
{{ value.shortname }}
</drag>
<span v-else-if="value.type == 'module'" class="disabled-competency">{{ value.shortname }}</span>
<span v-else class="competency-info">{{ value.shortname }}</span>
</span>
`,
});
Vue.component('t-competency-display', {
props: {
value : {
type: Object,
default: function(){ return {};},
},
},
data() {
return {
dragLine: null,
};
},
methods: {
},
computed: {
haschildren() {
return this.value.children && this.value.children.length > 0;
},
},
template: `
<li>
<span v-if="haschildren" v-b-toggle="'cmp-'+value.id">
<i class="when-closed fa fa-caret-right"></i>
<i class="when-open fa fa-caret-down"></i>
<t-competency-heading v-model="value"></t-competency-heading>
</span>
<span v-else>
<t-competency-heading v-model="value"></t-competency-heading>
</span>
<b-collapse v-if="haschildren" :id="'cmp-'+value.id">
<t-competency-list v-model="value.children"></t-competency-list>
</b-collapse>
</li>
`,
});
Vue.component('t-competency-list', {
props: {
value : {
type: Array,
default: function(){ return [];},
},
},
data() {
return {
};
},
methods: {
},
template: `
<ul class='t-competency-list'>
<t-competency-display
v-for="(child, index) in value"
:key='child.idnumber'
v-model="value[index]"
></t-competency-display>
</ul>
`,
});
Vue.component('t-item-junction',{ Vue.component('t-item-junction',{
props: { props: {
value : { value : {
@ -3270,6 +3247,14 @@ export default {
}; };
}, },
methods: { methods: {
makeType(){
return {
item: false,
component: true,
span: 1, //TODO: Detect longer courses and give them an appropriate span
type: 'gradable',
};
},
}, },
template: ` template: `
<ul class="t-course-list"> <ul class="t-course-list">
@ -3277,7 +3262,7 @@ export default {
<drag <drag
class="draggable-course" class="draggable-course"
:data="course" :data="course"
type="course" :type="makeType()"
@cut="" @cut=""
> >
<i class="t-course-list-item fa fa-book"></i> {{ course.shortname }} - {{ course.fullname }} <i class="t-course-list-item fa fa-book"></i> {{ course.shortname }} - {{ course.fullname }}

View file

@ -124,7 +124,7 @@ export default {
}, },
methods: { methods: {
onHeaderHeightChange(newheight){ onHeaderHeightChange(newheight){
console.info("Height change event to",newheight); //console.info("Height change event to",newheight);
this.$refs.main.style.height = `${newheight}px`; this.$refs.main.style.height = `${newheight}px`;
} }
}, },

View file

@ -84,6 +84,7 @@ class studyitem {
"conditions"=> new \external_value(PARAM_TEXT, 'conditions for completion'), "conditions"=> new \external_value(PARAM_TEXT, 'conditions for completion'),
"slot" => new \external_value(PARAM_INT, 'slot in the study plan'), "slot" => new \external_value(PARAM_INT, 'slot in the study plan'),
"layer" => new \external_value(PARAM_INT, 'layer in the slot'), "layer" => new \external_value(PARAM_INT, 'layer in the slot'),
"span" => new \external_value(PARAM_INT, 'how many periods the item spans'),
"course" => courseinfo::editor_structure(VALUE_OPTIONAL), "course" => courseinfo::editor_structure(VALUE_OPTIONAL),
"badge" => badgeinfo::editor_structure(VALUE_OPTIONAL), "badge" => badgeinfo::editor_structure(VALUE_OPTIONAL),
"continuation_id" => new \external_value(PARAM_INT, 'id of continued item'), "continuation_id" => new \external_value(PARAM_INT, 'id of continued item'),
@ -110,6 +111,7 @@ class studyitem {
'conditions' => $this->r->conditions, 'conditions' => $this->r->conditions,
'slot' => $this->r->slot, 'slot' => $this->r->slot,
'layer' => $this->r->layer, 'layer' => $this->r->layer,
'span' => $this->r->span,
'continuation_id' => $this->r->continuation_id, 'continuation_id' => $this->r->continuation_id,
'connections' => [ 'connections' => [
"in" => [], "in" => [],
@ -332,6 +334,7 @@ class studyitem {
"completion" => new \external_value(PARAM_TEXT, 'completion state (incomplete|progress|completed|excellent)'), "completion" => new \external_value(PARAM_TEXT, 'completion state (incomplete|progress|completed|excellent)'),
"slot" => new \external_value(PARAM_INT, 'slot in the study plan'), "slot" => new \external_value(PARAM_INT, 'slot in the study plan'),
"layer" => new \external_value(PARAM_INT, 'layer in the slot'), "layer" => new \external_value(PARAM_INT, 'layer in the slot'),
"span" => new \external_value(PARAM_INT, 'how many periods the item spans'),
"course" => courseinfo::user_structure(VALUE_OPTIONAL), "course" => courseinfo::user_structure(VALUE_OPTIONAL),
"badge" => badgeinfo::user_structure(VALUE_OPTIONAL), "badge" => badgeinfo::user_structure(VALUE_OPTIONAL),
"continuation" => self::link_structure(VALUE_OPTIONAL), "continuation" => self::link_structure(VALUE_OPTIONAL),
@ -352,6 +355,7 @@ class studyitem {
'completion' => completion::label($this->completion($userid)), 'completion' => completion::label($this->completion($userid)),
'slot' => $this->r->slot, 'slot' => $this->r->slot,
'layer' => $this->r->layer, 'layer' => $this->r->layer,
'span' => $this->r->span,
'connections' => [ 'connections' => [
"in" => [], "in" => [],
"out" => [], "out" => [],

View file

@ -366,11 +366,11 @@ ul.t-competency-list li {
} }
.t-slot-drop.secondary { .t-slot-drop.secondary {
min-height: 0px; min-height: 3px;
} }
.t-slot-drop.secondary.drop-allowed { .t-slot-drop.secondary.drop-allowed {
min-height: 12px; min-height: 3px;
} }
.t-slot-item.feedback { .t-slot-item.feedback {

View file

@ -147,21 +147,21 @@ print $OUTPUT->header();
<b-tab title="<?php t('toolbox')?>"> <b-tab title="<?php t('toolbox')?>">
<ul class="t-toolbox"> <ul class="t-toolbox">
<li><drag <li><drag
type="filter" :type="filterComponentType"
:data="{type: 'junction'}" :data="{type: 'junction'}"
@cut="" @cut=""
><t-item-junction></t-item-junction><?php t("tool-junction") ?> ><t-item-junction></t-item-junction><?php t("tool-junction") ?>
<template v-slot:drag-image="{data}"><t-item-junction></t-item-junction></template> <template v-slot:drag-image="{data}"><t-item-junction></t-item-junction></template>
</drag></li> </drag></li>
<li><drag <li><drag
type="filter" :type="filterComponentType"
:data="{type: 'finish'}" :data="{type: 'finish'}"
@cut="" @cut=""
><t-item-finish></t-item-finish><?php t("tool-finish") ?> ><t-item-finish></t-item-finish><?php t("tool-finish") ?>
<template v-slot:drag-image="{data}"><t-item-finish></t-item-finish></template> <template v-slot:drag-image="{data}"><t-item-finish></t-item-finish></template>
</drag></li> </drag></li>
<li><drag <li><drag
type="filter" :type="filterComponentType"
:data="{type: 'start'}" :data="{type: 'start'}"
@cut="" @cut=""
><t-item-start></t-item-start><?php t("tool-start") ?> ><t-item-start></t-item-start><?php t("tool-start") ?>
@ -173,7 +173,7 @@ print $OUTPUT->header();
<ul class="t-badges"> <ul class="t-badges">
<li v-for="b in badges"><img :src="b.imageurl" :alt="b.name"><drag <li v-for="b in badges"><img :src="b.imageurl" :alt="b.name"><drag
class="t-badge-drag" class="t-badge-drag"
type="filter" :type="filterComponentType"
:data="{type: 'badge', badge: b}" :data="{type: 'badge', badge: b}"
@cut="" @cut=""
>{{b.name}} >{{b.name}}