diff --git a/resources/assets/stylesheets/scss/courseware/layouts/tree.scss b/resources/assets/stylesheets/scss/courseware/layouts/tree.scss index 5f976a22a2a0ae5cda5fdcc60cc82c21c121ea7b..c74c4214b7024139a4e9c23bb659a403c9df1769 100644 --- a/resources/assets/stylesheets/scss/courseware/layouts/tree.scss +++ b/resources/assets/stylesheets/scss/courseware/layouts/tree.scss @@ -2,7 +2,6 @@ ol { list-style: none; padding-left: 1.25em; - margin-bottom: 20px; &.cw-tree-root-list { padding-left: 0; @@ -33,6 +32,10 @@ border-bottom: solid thin var(--content-color-40); margin-bottom: 12px; + > .cw-sortable-handle { + margin-bottom: 2px; + } + > a.cw-tree-item-link { display: inline-block; width: calc(100% - 4px); @@ -48,8 +51,13 @@ ol { padding-left: 0.25em; + &.cw-tree-adder-list, &.cw-tree-draggable-list { - padding-left: 1em; + padding-left: 2px; + } + &.cw-tree-adder-list { + margin-left: 16px; + height: 30px; } > li.cw-tree-item { @@ -57,27 +65,36 @@ > .cw-tree-item-wrapper { border: none; + line-height: 28px; - > a.cw-tree-item-link { - display: inline-block; + > a.cw-tree-item-link, + form { border-bottom: none; font-size: 14px; width: calc(100% - 20px); background-repeat: no-repeat; + background-position-y: center; padding-left: 18px; margin-left: 4px; margin-bottom: 0; + line-height: 28px; &.cw-tree-item-link-edit { width: calc(100% - 38px); } @include background-icon(bullet-dot, clickable, 18); + } + form { + display: inline; + background-position-y: center; + } + > a.cw-tree-item-link { + display: inline-block; &:hover { @include background-icon(bullet-dot, attention, 18); } - &.cw-tree-item-link-current { @include background-icon(bullet-dot, info, 18); } @@ -95,14 +112,44 @@ } } - .cw-sortable-handle { - vertical-align: middle; + .cw-tree-item-wrapper { + .cw-sortable-handle { + vertical-align: middle; + opacity: 0; + &:focus { + opacity: 1; + } + } + &:hover { + .cw-sortable-handle { + opacity: 1; + } + } } .cw-tree-item-link { + .cw-tree-item-edit-button { + opacity: 0; + padding: 0 4px; + vertical-align: bottom; + border: none; + background-color: transparent; + cursor: pointer; + img { + vertical-align: middle; + } + &:focus { + opacity: 1; + } + } + &:hover { background-color: var(--light-gray-color-20); color: var(--active-color); + + .cw-tree-item-edit-button { + opacity: 1; + } } &.cw-tree-item-link-current { @@ -122,10 +169,10 @@ @each $type, $icon in $tree-item-flag-icons { .cw-tree-item-flag-#{$type} { - display: inline-block; width: 16px; height: 16px; vertical-align: top; + float: right; @include background-icon(#{$icon}, clickable, 16); } @@ -159,5 +206,33 @@ .cw-tree-item-ghost { opacity: 0.6; + border: dashed 2px var(--content-color-40); + height: 28px; + margin-left: 24px !important; + + .cw-tree-item-wrapper { + visibility: hidden; + } + } + + form.cw-tree-item-adder-form, + form.cw-tree-item-updater { + display: inline; + + input { + height: 22px; + } + + button { + min-width: unset; + height: 28px; + width: 28px; + padding: 0; + margin: -1px 0 0 0; + + &::before { + margin: 4px; + } + } } } diff --git a/resources/assets/stylesheets/scss/courseware/variables.scss b/resources/assets/stylesheets/scss/courseware/variables.scss index af97f927b570ca9e37050bfb0bcc8584fbda6996..8ce915f3a209cdfacd51eb11dafc3a9225de2ee7 100644 --- a/resources/assets/stylesheets/scss/courseware/variables.scss +++ b/resources/assets/stylesheets/scss/courseware/variables.scss @@ -10,7 +10,7 @@ $element-icons: ( $tree-item-flag-icons: ( date: date, - write: edit, + write: community, cant-read: lock-locked2, ); diff --git a/resources/vue/components/courseware/structural-element/CoursewareTree.vue b/resources/vue/components/courseware/structural-element/CoursewareTree.vue index 4103c1bbe5c7f5c13b3b2ce26b5d363c116c2344..68947e3998e5197a16e62190d869c4d45740518e 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareTree.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareTree.vue @@ -18,6 +18,7 @@ @moveItemNextLevel="moveItemNextLevel" @childrenUpdated="updateNestedChildren" ></courseware-tree-item> + <courseware-tree-item-adder v-if="editMode && canEditRoot" :parentId="rootElement.id"/> </ol> <studip-progress-indicator v-else @@ -28,6 +29,7 @@ <script> import CoursewareTreeItem from './CoursewareTreeItem.vue'; +import CoursewareTreeItemAdder from './CoursewareTreeItemAdder.vue'; import StudipProgressIndicator from '../../StudipProgressIndicator.vue'; import { mapActions, mapGetters } from 'vuex'; @@ -35,6 +37,7 @@ import { mapActions, mapGetters } from 'vuex'; export default { components: { CoursewareTreeItem, + CoursewareTreeItemAdder, StudipProgressIndicator }, name: 'courseware-tree', @@ -78,6 +81,9 @@ export default { } }, + canEditRoot() { + return this.rootElement.attributes['can-edit']; + }, editMode() { return this.viewMode === 'edit'; }, diff --git a/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue b/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue index 820b58a6f38f932caac442f0e30f05457fecc1ff..a4bfa35e48cc43cf2bdd3b55f230a7540be27996 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue @@ -1,8 +1,5 @@ <template> - <li v-if="showItem" - :draggable="editMode ? true : null" - :aria-selected="editMode ? keyboardSelected : null" - > + <li v-if="showItem" :draggable="editMode ? true : null" :aria-selected="editMode ? keyboardSelected : null"> <div class="cw-tree-item-wrapper"> <span v-if="editMode && depth > 0 && canEdit" @@ -14,16 +11,27 @@ @keydown="handleKeyEvent" > </span> + <courseware-tree-item-updater + v-if="editMode && editingItem" + :structuralElement="element" + @close="editingItem = false" + @childrenUpdated="$emit('childrenUpdated')" + /> <router-link + v-else :to="'/structural_element/' + element.id" class="cw-tree-item-link" :class="{ 'cw-tree-item-link-current': isCurrent, 'cw-tree-item-link-edit': editMode, - 'cw-tree-item-link-selected': keyboardSelected + 'cw-tree-item-link-selected': keyboardSelected, }" > - {{ element.attributes?.title || "–" }} + {{ element.attributes?.title || '–' }} + <button v-if="editMode && canEdit" class="cw-tree-item-edit-button" @click.prevent="editingItem = true"> + <studip-icon shape="edit" /> + </button> + <span v-if="task">| {{ solverName }}</span> <span v-if="hasReleaseOrWithdrawDate" @@ -50,14 +58,11 @@ <span v-else class="cw-tree-item-sequential cw-tree-item-sequential-percentage" - :title="$gettextInterpolate( - $gettext('Fortschritt: %{progress}%'), - {progress: itemProgress} - )" + :title="$gettextInterpolate($gettext('Fortschritt: %{progress}%'), { progress: itemProgress })" > {{ itemProgress }} % </span> - </template> + </template> </router-link> </div> <ol @@ -85,11 +90,11 @@ handle=".cw-sortable-handle" v-bind="dragOptions" :elementId="element.id" - :list="nestedChildren" + :list="nestedChildren" :group="{ name: 'g1' }" @end="endDrag" > - <courseware-tree-item + <courseware-tree-item v-for="el in nestedChildren" :key="el.id" :element="el" @@ -108,17 +113,28 @@ @childrenUpdated="$emit('childrenUpdated')" /> </draggable> + <ol + v-if="editMode && canEdit && isFirstLevel" + class="cw-tree-adder-list" + > + <courseware-tree-item-adder :parentId="element.id" /> + </ol> </li> </template> <script> +import CoursewareTreeItemAdder from './CoursewareTreeItemAdder.vue'; +import CoursewareTreeItemUpdater from './CoursewareTreeItemUpdater.vue'; import draggable from 'vuedraggable'; + import { mapGetters, mapActions } from 'vuex'; export default { name: 'courseware-tree-item', components: { - draggable + CoursewareTreeItemAdder, + CoursewareTreeItemUpdater, + draggable, }, props: { element: { @@ -134,22 +150,23 @@ export default { }, keyboardSelectedProp: { type: Boolean, - default: false + default: false, }, newPos: { - type: Number + type: Number, }, newParentId: { - type: Number + type: Number, }, siblingCount: { - type: Number - } + type: Number, + }, }, data() { return { - keyboardSelected: false - } + keyboardSelected: false, + editingItem: false, + }; }, computed: { ...mapGetters({ @@ -168,8 +185,10 @@ export default { return { attrs: { role: 'listbox', - ['aria-label']: this.$gettextInterpolate(this.$gettext('Unterseiten von %{elementName}'),{ elementName: this.element.attributes?.title }) - } + ['aria-label']: this.$gettextInterpolate(this.$gettext('Unterseiten von %{elementName}'), { + elementName: this.element.attributes?.title, + }), + }, }; }, children() { @@ -179,7 +198,8 @@ export default { return this.childrenById(this.element.id) .map((id) => this.structuralElementById({ id })) - .filter(Boolean).sort((a,b) => a.attributes.position - b.attributes.position); + .filter(Boolean) + .sort((a, b) => a.attributes.position - b.attributes.position); }, nestedChildren() { return this.element.nestedChildren ?? []; @@ -198,7 +218,8 @@ export default { }, hasReleaseOrWithdrawDate() { return ( - this.element.attributes?.['release-date'] !== null || this.element.attributes?.['withdraw-date'] !== null + this.element.attributes?.['release-date'] !== null || + this.element.attributes?.['withdraw-date'] !== null ); }, hasWriteApproval() { @@ -207,7 +228,10 @@ export default { if (!writeApproval || Object.keys(writeApproval).length === 0) { return false; } - return (writeApproval.all || writeApproval.groups.length > 0 || writeApproval.users.length > 0) && this.element.attributes?.['can-edit']; + return ( + (writeApproval.all || writeApproval.groups.length > 0 || writeApproval.users.length > 0) && + this.element.attributes?.['can-edit'] + ); }, hasNoReadApproval() { if (this.context.type === 'users') { @@ -286,11 +310,11 @@ export default { return { animation: 0, disabled: false, - ghostClass: "cw-tree-item-ghost" + ghostClass: 'cw-tree-item-ghost', }; }, canEdit() { - return this.element.attributes['can-edit']; + return this.element.attributes?.['can-edit'] ?? false; }, inCourse() { return this.context.type === 'courses'; @@ -308,12 +332,12 @@ export default { methods: { ...mapActions({ loadTask: 'loadTask', - setAssistiveLiveContents: 'setAssistiveLiveContents' + setAssistiveLiveContents: 'setAssistiveLiveContents', }), endDrag(e) { let sortArray = []; for (let child of e.to.childNodes) { - sortArray.push({id: child.attributes.elementid.nodeValue, type: 'courseware-structural-elements'}); + sortArray.push({ id: child.attributes.elementid.nodeValue, type: 'courseware-structural-elements' }); } let data = { @@ -322,15 +346,15 @@ export default { oldPos: e.oldIndex, oldParent: e.item._underlying_vm_.relationships.parent.data.id, newParent: e.to.__vue__.$attrs.elementId, - sortArray: sortArray + sortArray: sortArray, }; if (data.oldParent === data.newParent && data.oldPos === data.newPos) { return; } if (data.oldParent !== data.newParent) { - sortArray.splice(data.newPos, 0, {id: data.id, type: 'courseware-structural-elements'}); - } + sortArray.splice(data.newPos, 0, { id: data.id, type: 'courseware-structural-elements' }); + } data.sortArray = sortArray; this.$emit('sort', data); @@ -346,20 +370,25 @@ export default { this.storeKeyboardSorting(); } else { this.keyboardSelected = true; - const assistiveLive = - this.$gettextInterpolate( - this.$gettext('%{elementTitle} ausgewählt. Aktuelle Position in der Liste: %{pos} von %{listLength}. Drücken Sie die Aufwärts- und Abwärtspfeiltasten, um die Position zu ändern, die Leertaste zum Ablegen, die Escape-Taste zum Abbrechen. Mit Pfeiltasten links und rechts kann die Position in der Hierarchie verändert werden.'), - { elementTitle: this.element.attributes.title, pos: this.element.attributes.position + 1, listLength: this.siblingCount } - ); + const assistiveLive = this.$gettextInterpolate( + this.$gettext( + '%{elementTitle} ausgewählt. Aktuelle Position in der Liste: %{pos} von %{listLength}. Drücken Sie die Aufwärts- und Abwärtspfeiltasten, um die Position zu ändern, die Leertaste zum Ablegen, die Escape-Taste zum Abbrechen. Mit Pfeiltasten links und rechts kann die Position in der Hierarchie verändert werden.' + ), + { + elementTitle: this.element.attributes.title, + pos: this.element.attributes.position + 1, + listLength: this.siblingCount, + } + ); - this.setAssistiveLiveContents(assistiveLive); + this.setAssistiveLiveContents(assistiveLive); } break; } if (this.keyboardSelected) { const data = { element: this.element, - parents: [] + parents: [], }; switch (e.keyCode) { case 27: // esc @@ -371,7 +400,7 @@ export default { this.$emit('moveItemPrevLevel', data); break; case 38: // up - e.preventDefault(); + e.preventDefault(); this.$emit('moveItemUp', data); break; case 39: // right @@ -379,7 +408,7 @@ export default { this.$emit('moveItemNextLevel', data); break; case 40: // down - e.preventDefault(); + e.preventDefault(); this.$emit('moveItemDown', data); break; } @@ -403,10 +432,9 @@ export default { }, abortKeyboardSorting() { this.$emit('childrenUpdated'); - const assistiveLive = this.$gettextInterpolate( - this.$gettext('%{elementTitle}. Neuordnung abgebrochen.'), - { elementTitle: this.element.attributes.title } - ); + const assistiveLive = this.$gettextInterpolate(this.$gettext('%{elementTitle}. Neuordnung abgebrochen.'), { + elementTitle: this.element.attributes.title, + }); this.setAssistiveLiveContents(assistiveLive); this.$nextTick(() => { this.keyboardSelected = false; @@ -419,14 +447,14 @@ export default { oldPos: this.element.attributes.position, oldParent: this.element.relationships.parent.data.id, newParent: this.element.newParentId, - sortArray: this.element.sortArray + sortArray: this.element.sortArray, }; this.keyboardSelected = false; if (data.newParent === undefined || data.newPos === undefined) { const assistiveLive = this.$gettextInterpolate( this.$gettext('%{elementTitle}. Neuordnung nicht möglich.'), - {elementTitle: this.element.attributes.title} + { elementTitle: this.element.attributes.title } ); this.setAssistiveLiveContents(assistiveLive); return; @@ -435,18 +463,18 @@ export default { if (data.oldParent === data.newParent && data.oldPos === data.newPos) { const assistiveLive = this.$gettextInterpolate( this.$gettext('%{elementTitle}. Neuordnung abgebrochen.'), - {elementTitle: this.element.attributes.title} + { elementTitle: this.element.attributes.title } ); this.setAssistiveLiveContents(assistiveLive); return; } this.$emit('sort', data); const assistiveLive = this.$gettextInterpolate( - this.$gettext('%{elementTitle}, abgelegt. Entgültige Position in der Liste: %{pos} von %{listLength}.'), - {elementTitle: this.element.attributes.title, pos: data.newPos + 1, listLength: this.siblingCount } + this.$gettext('%{elementTitle}, abgelegt. Entgültige Position in der Liste: %{pos} von %{listLength}.'), + { elementTitle: this.element.attributes.title, pos: data.newPos + 1, listLength: this.siblingCount } ); this.setAssistiveLiveContents(assistiveLive); - } + }, }, mounted() { if (this.element.relationships?.task?.data) { @@ -468,6 +496,6 @@ export default { this.keyboardSelected = true; this.$refs.sortableHandle.focus(); }, - } + }, }; </script> diff --git a/resources/vue/components/courseware/structural-element/CoursewareTreeItemAdder.vue b/resources/vue/components/courseware/structural-element/CoursewareTreeItemAdder.vue new file mode 100644 index 0000000000000000000000000000000000000000..fc9ca44bbcbd03f7c6a1d52504d6c9bb1aaa5a65 --- /dev/null +++ b/resources/vue/components/courseware/structural-element/CoursewareTreeItemAdder.vue @@ -0,0 +1,120 @@ +<template> + <li class="cw-tree-item cw-tree-item-adder"> + <div class="cw-tree-item-wrapper"> + <form v-if="showForm" class="cw-tree-item-adder-form" @submit.prevent=""> + <input type="text" v-model="elementTitle" :placeholder="$gettext('Titel')" /> + <button class="button accept" :title="$gettext('Seite erstellen')" @click="createElement"></button> + <button class="button cancel" :title="$gettext('Abbrechen')" @click="closeForm"></button> + </form> + <button class="add-element" v-else :title="$gettext('Seite hinzufügen')" @click="showForm = true"> + <studip-icon shape="add" /> + </button> + </div> + </li> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-tree-item-adder', + props: { + parentId: { + type: String, + required: true, + }, + }, + data() { + return { + showForm: false, + elementTitle: '', + }; + }, + computed: { + ...mapGetters({ + lastCreatedStructuralElement: 'courseware-structural-elements/lastCreated', + structuralElementById: 'courseware-structural-elements/byId', + currentElement: 'currentElement', + }), + }, + methods: { + ...mapActions({ + createStructuralElementWithTemplate: 'createStructuralElementWithTemplate', + loadStructuralElementById: 'courseware-structural-elements/loadById', + companionError: 'companionError', + companionInfo: 'companionInfo', + }), + closeForm() { + this.showForm = false; + this.elementTitle = ''; + }, + async createElement() { + this.elementTitle = this.elementTitle.trim(); + if (this.elementTitle === '') { + this.companionInfo({ info: this.$gettext('Bitte geben Sie einen Titel für die neue Seite ein.') }); + + return; + } + + const element = { + attributes: { + title: this.elementTitle, + purpose: 'content', + payload: { + description: '', + color: 'studip-blue', + license_type: '', + required_time: '', + difficulty_start: '', + difficulty_end: '', + }, + }, + templateId: null, + parentId: this.parentId, + currentId: this.currentElement, + }; + + this.closeForm(); + + try { + await this.createStructuralElementWithTemplate(element); + } catch (e) { + let errorMessage = this.$gettext( + 'Es ist ein Fehler aufgetreten. Die Seite konnte nicht erstellt werden.' + ); + if (e.status === 403) { + errorMessage = this.$gettext( + 'Die Seite konnte nicht erstellt werden. Sie haben nicht die notwendigen Schreibrechte.' + ); + } + + this.companionError({ info: errorMessage }); + return; + } + + const newCreated = this.lastCreatedStructuralElement; + await this.loadStructuralElementById({ id: newCreated.id }); + const newElement = this.structuralElementById({ id: newCreated.id }); + + this.$router.push(newElement.id); + }, + }, +}; +</script> + +<style scoped lang="scss"> +.cw-tree-root-list > .cw-tree-item.cw-tree-item-adder > .cw-tree-item-wrapper { + border-bottom: none; +} +.cw-tree-item-adder { + .add-element { + border: none; + cursor: pointer; + background-color: transparent; + height: 28px; + img { + vertical-align: middle; + } + } +} +</style> diff --git a/resources/vue/components/courseware/structural-element/CoursewareTreeItemUpdater.vue b/resources/vue/components/courseware/structural-element/CoursewareTreeItemUpdater.vue new file mode 100644 index 0000000000000000000000000000000000000000..78690c9684156f719020a47e16d4fb3b317bab0a --- /dev/null +++ b/resources/vue/components/courseware/structural-element/CoursewareTreeItemUpdater.vue @@ -0,0 +1,91 @@ +<template> + <form class="cw-tree-item-updater" @submit.prevent=""> + <input type="text" v-model="elementTitle" :placeholder="$gettext('Titel')" /> + <button class="button accept" :title="$gettext('Speichern')" @click="updateElement"></button> + <button class="button cancel" :title="$gettext('Abbrechen')" @click="close"></button> + </form> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; + +export default { + name: 'courseware-tree-item-adder', + props: { + structuralElement: { + type: Object, + required: true, + }, + }, + data() { + return { + elementTitle: '', + }; + }, + computed: { + ...mapGetters({ + structuralElementById: 'courseware-structural-elements/byId', + userId: 'userId', + userById: 'users/byId', + }), + }, + methods: { + ...mapActions({ + loadStructuralElementById: 'courseware-structural-elements/loadById', + companionError: 'companionError', + companionInfo: 'companionInfo', + + updateStructuralElement: 'updateStructuralElement', + lockObject: 'lockObject', + unlockObject: 'unlockObject', + loadUser: 'users/loadById', + }), + close() { + this.$emit('close'); + }, + async updateElement() { + if (this.elementTitle === '') { + this.companionInfo({ + info: this.$gettext('Bitte geben Sie einen Titel für die Seite ein.'), + }); + return; + } + await this.loadStructuralElementById({ id: this.structuralElement.id }); + let element = this.structuralElementById({ id: this.structuralElement.id }); + element.attributes.title = this.elementTitle; + const blockerData = element?.relationships?.['edit-blocker']?.data; + const blocked = blockerData !== null && blockerData !== ''; + const blockedByAnotherUser = blocked && blockerData.id !== this.userId; + if (blockedByAnotherUser) { + this.close(); + await this.loadUser({ id: blockerData.id }); + const blocker = this.userById({ id: blockerData.id }); + this.companionWarning({ + info: this.$gettextInterpolate( + this.$gettext( + 'Ihre Änderungen konnten nicht gespeichert werden, da %{blockingUserName} die Bearbeitung übernommen hat.' + ), + { blockingUserName: blocker.attributes['formatted-name'] } + ), + }); + return; + } + if (!this.blocked) { + await this.lockObject({ id: this.structuralElement.id, type: 'courseware-structural-elements' }); + } + + await this.lockObject({ id: this.structuralElement.id, type: 'courseware-structural-elements' }); + await this.updateStructuralElement({ element, id: this.structuralElement.id }); + await this.unlockObject({ id: this.structuralElement.id, type: 'courseware-structural-elements' }); + this.$emit('childrenUpdated'); + this.close(); + }, + setTitle() { + this.elementTitle = this.structuralElement.attributes.title; + }, + }, + mounted() { + this.setTitle(); + }, +}; +</script>