diff options
| author | Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de> | 2023-11-14 11:57:16 +0100 |
|---|---|---|
| committer | Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de> | 2024-07-09 09:19:01 +0200 |
| commit | 62cc5d1f509b245159ffcbd0dbd08ab389e51615 (patch) | |
| tree | 84070ab147fdfa4ecb26767f42de7d1374a304c1 /resources | |
| parent | 2aa22a3decc515ef19681e3fbb303e395bfef6d4 (diff) | |
Add Peer Review on top of feature/better-tasks.feature/peerreview-6
Diffstat (limited to 'resources')
42 files changed, 3263 insertions, 34 deletions
diff --git a/resources/assets/stylesheets/scss/wizard.scss b/resources/assets/stylesheets/scss/wizard.scss index 5c73dc9..195b344 100644 --- a/resources/assets/stylesheets/scss/wizard.scss +++ b/resources/assets/stylesheets/scss/wizard.scss @@ -7,6 +7,7 @@ width: 270px; min-height: 440px; margin-top: 38px; + flex-shrink: 0; img { margin: auto; @@ -277,4 +278,3 @@ form.default fieldset.radiobutton-set { } } } - diff --git a/resources/vue/components/StudipActionMenu.vue b/resources/vue/components/StudipActionMenu.vue index 918814c..1070be0 100644 --- a/resources/vue/components/StudipActionMenu.vue +++ b/resources/vue/components/StudipActionMenu.vue @@ -55,6 +55,7 @@ <template v-for="item in navigationItems"> <label v-if="item.disabled" :key="item.id" aria-disabled="true" v-bind="item.attributes"> <studip-icon :shape="item.icon" + :alt="item.label" :title="item.label" role="inactive" class="action-menu-item-icon" @@ -63,6 +64,7 @@ <span v-else-if="item.type === 'separator'" :key="item.id" class="quiet">|</span> <a v-else :key="item.id" v-bind="item.attributes" v-on="linkEvents(item)"> <studip-icon :shape="item.icon" + :alt="item.label" :title="item.label" class="action-menu-item-icon" ></studip-icon> diff --git a/resources/vue/components/StudipArticle.vue b/resources/vue/components/StudipArticle.vue new file mode 100644 index 0000000..e91f69d --- /dev/null +++ b/resources/vue/components/StudipArticle.vue @@ -0,0 +1,63 @@ +<template> + <article class="studip" :class="{ collapsable, collapsed }" v-bind="$attrs"> + <header> + <h1 @click="doToggle"> + <template v-if="collapsable"> + <StudipIcon class="studip-articles--icon" shape="arr_1right" v-if="collapsed" /> + <StudipIcon class="studip-articles--icon" shape="arr_1down" v-else /> + </template> + <slot name="title" v-bind="{ isOpen: collapsed }"></slot> + </h1> + <slot v-if="$slots.titleplus" name="titleplus"></slot> + </header> + <section v-if="!collapsed"> + <slot name="body"></slot> + </section> + <footer v-if="$slots.footer"> + <slot name="footer"></slot> + </footer> + </article> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import StudipIcon from './StudipIcon.vue'; + +export default Vue.extend({ + props: { + collapsable: { + type: Boolean, + default: false, + }, + closed: { + type: Boolean, + default: false, + }, + }, + components: { StudipIcon }, + data() { + return { collapsed: this.closed }; + }, + methods: { + doToggle() { + if (this.collapsable) { + this.collapsed = !this.collapsed; + } + }, + }, +}); +</script> +<style scoped> +article.studip.collapsable.collapsed { + padding-block-end: 0; +} +article.studip.collapsable.collapsed > header { + margin-block-end: 0; +} +article.studip.collapsable > header > h1 { + cursor: pointer; +} + +.studip-articles--icon { +} +</style> diff --git a/resources/vue/components/StudipContentBox.vue b/resources/vue/components/StudipContentBox.vue new file mode 100644 index 0000000..ab5bf95 --- /dev/null +++ b/resources/vue/components/StudipContentBox.vue @@ -0,0 +1,46 @@ +<template> + <section class="contentbox"> + <header v-if="title || $slots.header"> + <slot name="header"> + <h1> + <StudipIcon v-if="icon" :shape="icon" /> + {{ title }} + </h1> + </slot> + <slot name="header-nav"> + <nav v-if="items"> + <StudipActionMenu :items="items" /> + </nav> + </slot> + </header> + + <slot></slot> + + <footer> + <slot name="footer"></slot> + </footer> + </section> +</template> + +<script> +import StudipActionMenu from './StudipActionMenu.vue'; +import StudipIcon from './StudipIcon.vue'; + +export default { + components: { StudipActionMenu, StudipIcon }, + props: { + icon: { + type: String, + required: false, + }, + items: { + type: Array, + required: false, + }, + title: { + type: String, + required: true, + }, + }, +}; +</script> diff --git a/resources/vue/components/StudipUserAvatar.vue b/resources/vue/components/StudipUserAvatar.vue new file mode 100644 index 0000000..df7bf5a --- /dev/null +++ b/resources/vue/components/StudipUserAvatar.vue @@ -0,0 +1,39 @@ +<template> + <div class="studip-user-avatar" :class="{ 'studip-user-avatar-small': small }"> + <span> + <img :src="avatarUrl" role="presentation" /> + </span> + <span>{{ formattedName }}</span> + </div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + avatarUrl: { + type: String, + required: true, + }, + formattedName: { + type: String, + required: true, + }, + small: { + type: Boolean, + default: false, + }, + }, +}); +</script> + +<style scoped> +.studip-user-avatar { + align-items: center; + display: flex; + gap: 0.25rem; +} +.studip-user-avatar-small img { + width: 1em; +} +</style> diff --git a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue index f130f48..929f6bc 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareStructuralElement.vue @@ -32,10 +32,12 @@ <li class="cw-ribbon-breadcrumb-item cw-ribbon-breadcrumb-item-current" :title="structuralElement.attributes.title" - > + > <span>{{ structuralElement.attributes.title || "–" }}</span> - <span v-if="isTask">[ {{ solverName }} ]</span> - <template v-if="!userIsTeacher && inCourse"> + <span v-if="task"> + [ {{ (userIsReviewer && isPeerReviewAnonymous) ? $gettext('anonym') : solverName }} ] + </span> + <template v-if="inCourse && !(userIsTeacher || userIsReviewer)"> <studip-icon v-if="complete" shape="accept" @@ -133,6 +135,18 @@ </button> </template> </courseware-companion-box> + <courseware-companion-box + v-for="peerReview in peerReviews" + :key="peerReview.id" + mood="pointing" + :msgCompanion="$gettext('Sie beurteilen diese Seite im Rahmen eines Peer-Reviews.')" + > + <template #companionActions> + <button class="button" @click="openPeerReviewForm(peerReview)"> + {{ $gettext('Peer-Review öffnen') }} + </button> + </template> + </courseware-companion-box> <courseware-empty-element-box v-if="empty && !showRootLayout" :canEdit="canEdit" @@ -610,6 +624,11 @@ <courseware-structural-element-dialog-export v-if="showExportDialog" :structuralElement="currentElement" /> <courseware-structural-element-dialog-export-pdf v-if="showPdfExportDialog" :structuralElement="currentElement" /> <courseware-structural-element-dialog-add-chooser v-if="showAddChooserDialog" /> + <PeerReviewAssessmentDialog + v-model="showPeerReviewForm" + v-if="selectedPeerReview" + :review="selectedPeerReview" + /> <feedback-dialog v-if="showFeedbackDialog" :feedbackElementId="parseInt(feedbackElementId)" @@ -669,6 +688,7 @@ import CoursewareStructuralElementDiscussion from './CoursewareStructuralElement import CoursewareStructuralElementPermissions from './CoursewareStructuralElementPermissions.vue'; import CoursewareContentPermissions from '../CoursewareContentPermissions.vue'; import CoursewareWelcomeScreen from './CoursewareWelcomeScreen.vue'; +import PeerReviewAssessmentDialog from '../tasks/peer-review/AssessmentDialog.vue'; import CoursewareExport from '@/vue/mixins/courseware/export.js'; import CoursewareOerMessage from '@/vue/mixins/courseware/oermessage.js'; import colorMixin from '@/vue/mixins/courseware/colors.js'; @@ -713,6 +733,7 @@ export default { StudipFiveStars, FocusTrap, IsoDate, + PeerReviewAssessmentDialog, StockImageSelector, StudipDialog, StudipProgressIndicator, @@ -779,6 +800,8 @@ export default { uploadImageURL: null, showStockImageSelector: false, selectedStockImage: null, + showPeerReviewForm: false, + selectedPeerReview: null, displayFeedback: false, showRatingPopup: false, @@ -795,6 +818,8 @@ export default { consumeMode: 'consumeMode', containerById: 'courseware-containers/byId', relatedContainers: 'courseware-containers/related', + relatedPeerReviewProcesses: 'courseware-peer-review-processes/related', + relatedPeerReviews: 'courseware-peer-reviews/related', relatedStructuralElements: 'courseware-structural-elements/related', getRelatedFeedback: 'courseware-structural-element-feedback/related', getRelatedComments: 'courseware-structural-element-comments/related', @@ -1409,6 +1434,27 @@ export default { ), { length: this.commentsCounter }); }, + userIsReviewer() { + return this.task ? this.task.attributes['can-peer-review'] : false; + }, + peerReviews() { + if (this.isTask && this.userIsReviewer) { + return this.relatedPeerReviews({ + parent: { id: this.task.id, type: this.task.type }, + relationship: 'peer-reviews', + }); + } + return []; + }, + isPeerReviewAnonymous() { + return this.peerReviews.every(({ id, type }) => { + const process = this.relatedPeerReviewProcesses({ + parent: { id, type }, + relationship: 'process', + }); + return process.attributes.configuration.anonymous; + }); + }, }, methods: { @@ -1951,7 +1997,11 @@ export default { submitFeedback() { this.showRatingPopup = false; this.companionSuccess({ info: this.$gettext('Feedback wurde abgegeben.') }); - } + }, + openPeerReviewForm(peerReview) { + this.selectedPeerReview = peerReview; + this.showPeerReviewForm = true; + }, }, created() { this.pluginManager.registerComponentsLocally(this); diff --git a/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue b/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue index 540e76b..faff077 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareTreeItem.vue @@ -32,7 +32,7 @@ <studip-icon shape="edit" /> </button> - <span v-if="task">| {{ solverName }}</span> + <span v-if="task">| {{ userIsReviewer ? $gettext("anonym") : solverName }}</span> <span v-if="hasReleaseOrWithdrawDate" class="cw-tree-item-flag-date" @@ -48,7 +48,7 @@ class="cw-tree-item-flag-cant-read" :title="$gettext('Diese Seite kann von Teilnehmenden nicht gesehen werden')" ></span> - <template v-if="!userIsTeacher && inCourse"> + <template v-if="!(userIsTeacher || userIsReviewer) && inCourse"> <span v-if="complete" class="cw-tree-item-sequential cw-tree-item-sequential-complete" @@ -326,6 +326,9 @@ export default { complete() { return this.itemProgress === 100; }, + userIsReviewer() { + return this.task ? this.task.attributes['can-peer-review'] : false; + }, }, methods: { ...mapActions({ diff --git a/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue index 426b0cb..5c7e794 100644 --- a/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue +++ b/resources/vue/components/courseware/tasks/CoursewareDashboardStudents.vue @@ -3,7 +3,8 @@ <CoursewareRibbon :isContentBar="true" :showToolbarButton="false"> <template #buttons> <router-link :to="{ name: 'task-groups-index' }"> - <StudipIcon shape="category-task" :size="24" /> + <StudipIcon shape="category-task" :size="24" aria-role="presentation" /> + <span class="sr-only">{{ $gettext('Aufgaben') }}</span> </router-link> </template> <template #breadcrumbList> @@ -24,6 +25,7 @@ <th :class="getSortClass('end-date')" @click="sort('end-date')"> {{ $gettext('Bearbeitungszeit') }} </th> + <th></th> <th class="actions">{{ $gettext('Aktionen') }}</th> </tr> </thead> @@ -44,9 +46,13 @@ }}</router-link> </td> <td> - <StudipDate :date="new Date(taskGroup.attributes['start-date'])" /> - <StudipDate - :date="new Date(taskGroup.attributes['end-date'])" - /> + <StudipDate :date="new Date(taskGroup.attributes['start-date'])" /> - + <StudipDate :date="new Date(taskGroup.attributes['end-date'])" /> + </td> + <td> + <div v-for="process in peerReviewProcesses(taskGroup)" :key="process.id"> + <PeerReviewProcessStatus :process="process" description :filter="processActive" /> + </div> </td> <td class="actions"> <StudipActionMenu @@ -68,7 +74,11 @@ </template> </CompanionBox> - <TaskGroupsAddSolversDialog v-if="showTaskGroupsAddSolversDialog" :taskGroup="selectedTaskGroup" @newtask="reloadTasks" /> + <TaskGroupsAddSolversDialog + v-if="showTaskGroupsAddSolversDialog" + :taskGroup="selectedTaskGroup" + @newtask="reloadTasks" + /> <TaskGroupsDeleteDialog v-if="showTaskGroupsDeleteDialog" :taskGroup="selectedTaskGroup" /> <TaskGroupsModifyDeadlineDialog v-if="showTaskGroupsModifyDeadlineDialog" :taskGroup="selectedTaskGroup" /> <CoursewareTasksDialogDistribute v-if="showTasksDistributeDialog" @newtask="reloadTasks" /> @@ -81,6 +91,7 @@ import { mapActions, mapGetters } from 'vuex'; import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; import CoursewareRibbon from '../structural-element/CoursewareRibbon.vue'; import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue'; +import PeerReviewProcessStatus from './peer-review/ProcessStatus.vue'; import StudipActionMenu from '../../StudipActionMenu.vue'; import StudipDate from '../../StudipDate.vue'; import StudipIcon from '../../StudipIcon.vue'; @@ -88,6 +99,7 @@ import TaskGroupsAddSolversDialog from './TaskGroupsAddSolversDialog.vue'; import TaskGroupsDeleteDialog from './TaskGroupsDeleteDialog.vue'; import TaskGroupsModifyDeadlineDialog from './TaskGroupsModifyDeadlineDialog.vue'; import { getStatus } from './task-groups-helper.js'; +import { ProcessStatus } from './peer-review/definitions.ts'; export default { name: 'courseware-dashboard-students', @@ -95,6 +107,7 @@ export default { CompanionBox, CoursewareRibbon, CoursewareTasksDialogDistribute, + PeerReviewProcessStatus, StudipActionMenu, StudipDate, StudipIcon, @@ -103,6 +116,7 @@ export default { TaskGroupsModifyDeadlineDialog, }, data: () => ({ + processActive: ProcessStatus.Active, selectedTaskGroup: null, sortBy: 'end-date', sortAsc: false, @@ -110,6 +124,7 @@ export default { computed: { ...mapGetters({ context: 'context', + relatedPeerReviewProcesses: 'courseware-peer-review-processes/related', showTaskGroupsAddSolversDialog: 'tasks/showTaskGroupsAddSolversDialog', showTaskGroupsDeleteDialog: 'tasks/showTaskGroupsDeleteDialog', showTaskGroupsModifyDeadlineDialog: 'tasks/showTaskGroupsModifyDeadlineDialog', @@ -155,13 +170,13 @@ export default { id: 'add-solvers', label: this.$gettext('Teilnehmende hinzufügen'), icon: 'add', - emit: 'addsolvers' + emit: 'addsolvers', }); menuItems.push({ id: 'modify-deadline', label: this.$gettext('Bearbeitungszeit verlängern'), icon: 'date', - emit: 'deadline' + emit: 'deadline', }); } @@ -186,11 +201,15 @@ export default { this.selectedTaskGroup = taskGroup; this.setShowTaskGroupsModifyDeadlineDialog(true); }, + peerReviewProcesses(parent) { + return this.relatedPeerReviewProcesses({ parent, relationship: 'peer-review-processes' }); + }, reloadTasks() { this.loadAllTasks({ options: { 'filter[cid]': this.context.id, - include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer', + include: + 'solver, structural-element, task-feedback, task-group, task-group.lecturer, task-group.peer-review-processes', }, }); }, @@ -216,7 +235,7 @@ export default { th { cursor: pointer; } -th:is(:first-child,:last-child) { +th:is(:first-child, :last-child) { cursor: not-allowed; } </style> diff --git a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue index e17d18e..7528225 100644 --- a/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue +++ b/resources/vue/components/courseware/tasks/PagesTaskGroupsShow.vue @@ -8,7 +8,8 @@ <CoursewareRibbon :isContentBar="true" :showToolbarButton="false"> <template #buttons> <router-link :to="{ name: 'task-groups-index' }"> - <StudipIcon shape="category-task" :size="24" /> + <StudipIcon shape="category-task" :size="24" aria-role="presentation" /> + <span class="sr-only">{{ $gettext('Aufgaben') }}</span> </router-link> </template> <template #breadcrumbList> @@ -25,6 +26,7 @@ :taskGroup="taskGroup" :tasks="tasksByGroup[taskGroup.id]" @add-feedback="onShowAddFeedback" + @add-peer-review-process="onShowPeerReviewProcessCreate" @edit-feedback="onShowEditFeedback" @solve-renewal="onShowSolveRenewal" /> @@ -48,6 +50,13 @@ @close="closeDialogs" /> + <PeerReviewProcessCreateDialog + v-if="showPeerReviewProcessCreate" + :taskGroup="taskGroup" + @create="onCreatePeerReviewProcess" + @close="closeDialogs" + /> + <RenewalDialog v-if="renewalTask" :renewalDate="renewalDate" @@ -71,6 +80,7 @@ import CoursewareRibbon from '../structural-element/CoursewareRibbon.vue'; import CoursewareTasksActionWidget from '../widgets/CoursewareTasksActionWidget.vue'; import CoursewareTasksDialogDistribute from './CoursewareTasksDialogDistribute.vue'; import EditFeedbackDialog from './EditFeedbackDialog.vue'; +import PeerReviewProcessCreateDialog from './peer-review/ProcessCreateDialog.vue'; import RenewalDialog from './RenewalDialog.vue'; import TaskGroup from './TaskGroup.vue'; import TaskGroupsAddSolversDialog from './TaskGroupsAddSolversDialog.vue'; @@ -85,6 +95,7 @@ export default { CoursewareTasksActionWidget, CoursewareTasksDialogDistribute, EditFeedbackDialog, + PeerReviewProcessCreateDialog, RenewalDialog, TaskGroup, TaskGroupsAddSolversDialog, @@ -97,6 +108,7 @@ export default { currentDialogFeedback: {}, renewalTask: null, showAddFeedbackDialog: false, + showPeerReviewProcessCreate: null, showEditFeedbackDialog: false, }; }, @@ -131,6 +143,7 @@ export default { ...mapActions({ companionError: 'companionError', companionSuccess: 'companionSuccess', + createPeerReviewProcess: 'tasks/createPeerReviewProcess', createTaskFeedback: 'createTaskFeedback', deleteTaskFeedback: 'deleteTaskFeedback', loadAllTasks: 'courseware-tasks/loadAll', @@ -141,6 +154,7 @@ export default { closeDialogs() { this.showAddFeedbackDialog = false; this.showEditFeedbackDialog = false; + this.showPeerReviewProcessCreate = false; this.currentDialogFeedback = {}; this.renewalTask = null; @@ -156,6 +170,11 @@ export default { this.createTaskFeedback({ taskFeedback: this.currentDialogFeedback }); this.closeDialogs(); }, + onCreatePeerReviewProcess(options) { + this.createPeerReviewProcess({ taskGroup: this.taskGroup, options }) + .then(() => this.loadTaskGroup(this.taskGroup)) + .then(() => this.closeDialogs()); + }, onShowAddFeedback(task) { this.currentDialogFeedback = { attributes: { content: '' }, @@ -174,6 +193,9 @@ export default { this.currentDialogFeedback = _.cloneDeep(feedback); this.showEditFeedbackDialog = true; }, + onShowPeerReviewProcessCreate() { + this.showPeerReviewProcessCreate = true; + }, onShowSolveRenewal(task) { this.renewalTask = _.cloneDeep(task); this.renewalTask.attributes['renewal-date'] = new Date().toISOString(); diff --git a/resources/vue/components/courseware/tasks/TaskGroup.vue b/resources/vue/components/courseware/tasks/TaskGroup.vue index 62449f1..ac0ea6a 100644 --- a/resources/vue/components/courseware/tasks/TaskGroup.vue +++ b/resources/vue/components/courseware/tasks/TaskGroup.vue @@ -12,7 +12,9 @@ <section v-if="tasks.length > 0"> <table class="default"> <caption> - {{ $gettext('Verteilte Aufgaben') }} + {{ + $gettext('Verteilte Aufgaben') + }} </caption> <thead> <tr> @@ -38,6 +40,12 @@ /> </tbody> </table> + + <PeerReviewProcesses + :taskGroup="taskGroup" + @add-peer-review-process="$emit('add-peer-review-process', taskGroup)" + class="cw-task-group-peer-review-processes" + /> </section> <div v-else> <CompanionBox mood="pointing" :msgCompanion="$gettext('Diese Aufgabe wurde an niemanden verteilt.')" /> @@ -46,30 +54,19 @@ </template> <script> -import { mapGetters } from 'vuex'; import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; import StudipDate from '../../StudipDate.vue'; +import PeerReviewProcesses from './TaskGroupPeerReviewProcesses.vue'; import TaskItem from './TaskGroupTaskItem.vue'; import { getStatus } from './task-groups-helper.js'; export default { - components: { CompanionBox, StudipDate, TaskItem }, + components: { CompanionBox, PeerReviewProcesses, StudipDate, TaskItem }, props: ['taskGroup', 'tasks'], computed: { - ...mapGetters({ - coursewareContext: 'context', - }), - actionMenuContext() { - return this.$gettextInterpolate(this.$gettext('Courseware-Aufgabe "%{ taskGroup }"'), { - taskGroup: this.taskGroup.attributes.title, - }); - }, endDate() { return new Date(this.taskGroup.attributes['end-date']); }, - isAfter() { - return new Date() > this.endDate; - }, startDate() { return new Date(this.taskGroup.attributes['start-date']); }, @@ -82,3 +79,9 @@ export default { }, }; </script> + +<style scoped> +.cw-task-group-peer-review-processes { + margin-block-start: 3rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcesses.vue b/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcesses.vue new file mode 100644 index 0000000..631fe06 --- /dev/null +++ b/resources/vue/components/courseware/tasks/TaskGroupPeerReviewProcesses.vue @@ -0,0 +1,158 @@ +<template> + <div> + <StudipArticle> + <template #title> {{ $gettext('Peer-Review-Verfahren') }} </template> + <template #body> + <CompanionBox + v-if="!hasPeerReviewProcesses" + mood="pointing" + :msgCompanion="$gettext('Für diese Aufgabe wurde noch kein Peer-Review-Verfahren aktiviert.')" + > + <template #companionActions> + <button class="button" @click="$emit('add-peer-review-process')"> + {{ $gettext('Peer-Review-Verfahren aktivieren') }} + </button> + </template> + </CompanionBox> + <ProcessDetail + v-for="process in peerReviewProcesses" + :key="process.id" + :process="process" + @show-assessment-type-editor="onShowAssessmentTypeEditor(process)" + @show-pairing-editor="onShowPairingEditor(process)" + @change-peer-review-process-duration="onShowPeerReviewProcessDuration(process)" + @edit-peer-review-process="onShowPeerReviewProcessEdit(process)" + /> + </template> + </StudipArticle> + + <AssessmentTypeEditorDialog + v-if="showAssessmentTypeEditor" + v-model="showAssessmentTypeEditor" + :process="selectedProcess" + @update="onUpdateAssessmentType" + /> + <PairingEditorDialog v-model="showPairingEditor" :process="selectedProcess" @update="onUpdatePairing" /> + <ProcessEditDialog + v-if="showPeerReviewProcessEdit" + :process="selectedProcess" + @update="onUpdatePeerReviewProcess" + @close="showPeerReviewProcessEdit = false" + /> + <ProcessDurationDialog + v-model="showPeerReviewProcessDuration" + :process="selectedProcess" + @update="onUpdateDuration" + /> + </div> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; +import AssessmentTypeEditorDialog from './peer-review/AssessmentTypeEditorDialog.vue'; +import CompanionBox from '../layouts/CoursewareCompanionBox.vue'; +import PairingEditorDialog from './peer-review/PairingEditorDialog.vue'; +import ProcessDetail from './peer-review/ProcessDetail.vue'; +import ProcessDurationDialog from './peer-review/ProcessDurationDialog.vue'; +import ProcessEditDialog from './peer-review/ProcessEditDialog.vue'; +import StudipArticle from '../../StudipArticle.vue'; +import { getStatus } from './task-groups-helper.js'; + +export default { + components: { + AssessmentTypeEditorDialog, + CompanionBox, + PairingEditorDialog, + ProcessDetail, + ProcessDurationDialog, + ProcessEditDialog, + StudipArticle, + }, + props: ['taskGroup'], + data: () => ({ + selectedProcess: null, + showAssessmentTypeEditor: false, + showPairingEditor: false, + showPeerReviewProcessDuration: false, + showPeerReviewProcessEdit: false, + }), + computed: { + ...mapGetters({ + relatedPeerReviewProcesses: 'courseware-peer-review-processes/related', + }), + hasPeerReviewProcesses() { + return !!this.peerReviewProcesses; + }, + isAfter() { + return new Date() > new Date(this.taskGroup.attributes['end-date']); + }, + peerReviewProcesses() { + return this.relatedPeerReviewProcesses({ parent: this.taskGroup, relationship: 'peer-review-processes' }); + }, + }, + methods: { + ...mapActions({ + loadRelatedPeerReviews: 'courseware-peer-reviews/loadRelated', + replacePairings: 'tasks/replacePairings', + updatePeerReviewProcess: 'tasks/updatePeerReviewProcess', + }), + loadPeerReviews() { + return this.loadRelatedPeerReviews({ + parent: this.process, + relationship: 'peer-reviews', + options: { include: 'reviewer,task' }, + }); + }, + onShowAssessmentTypeEditor(process) { + this.selectedProcess = process; + this.showAssessmentTypeEditor = true; + }, + onShowPairingEditor(process) { + this.selectedProcess = process; + this.showPairingEditor = true; + }, + onShowPeerReviewProcessDuration(process) { + console.debug('change-peer-review-process-duration', process); + this.selectedProcess = process; + this.showPeerReviewProcessDuration = true; + }, + onShowPeerReviewProcessEdit(process) { + this.selectedProcess = process; + this.showPeerReviewProcessEdit = true; + }, + onUpdateAssessmentType(payload) { + const configuration = this.selectedProcess.attributes.configuration; + configuration.payload = payload; + + this.updatePeerReviewProcess({ process: this.selectedProcess, configuration }).then(() => { + this.selectedProcess = null; + this.showAssessmentTypeEditor = false; + }); + }, + onUpdateDuration(duration) { + const configuration = { ...this.selectedProcess.attributes.configuration, duration }; + this.updatePeerReviewProcess({ process: this.selectedProcess, configuration }).then(() => { + this.selectedProcess = null; + this.showPeerReviewProcessDuration = false; + }); + }, + onUpdatePairing(pairings) { + this.replacePairings({ process: this.selectedProcess, pairings }) + .then(() => this.loadPeerReviews()) + .then(() => { + this.selectedProcess = null; + this.showPairingEditor = false; + }) + .catch((error) => { + console.error('Could not replace pairings.', error); + }); + }, + onUpdatePeerReviewProcess({ configuration }) { + this.updatePeerReviewProcess({ process: this.selectedProcess, configuration }).then(() => { + this.selectedProcess = null; + this.showPeerReviewProcessEdit = false; + }); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue new file mode 100644 index 0000000..135fd48 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentDialog.vue @@ -0,0 +1,113 @@ +<template> + <StudipDialog + v-if="show" + :title="$gettext('Peer-Review verfassen')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :confirmDisabled="!isActive" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="700" + width="700" + @close="onClose" + @confirm="onConfirm" + > + <template #dialogContent> + <CompanionBox + v-if="!isActive" + mood="sad" + :msgCompanion=" + $gettext( + 'Das Peer-Review-Verfahren ist abgeschlossen. Sie können das Peer-Review nicht mehr ändern.' + ) + " + /> + <component + v-bind:is="assessmentComponent" + :disabled="!isActive" + :process="process" + :review="review" + @answer="onAnswer" + ></component> + </template> + </StudipDialog> +</template> + +<script> +import AssessmentTypeForm from './assessment-types/forms/AssessmentTypeForm.vue'; +import AssessmentTypeFreetext from './assessment-types/forms/AssessmentTypeFreetext.vue'; +import AssessmentTypeTable from './assessment-types/forms/AssessmentTypeTable.vue'; +import { getProcessStatus, ProcessStatus } from './definitions.ts'; +import CompanionBox from '../../layouts/CoursewareCompanionBox.vue'; +import StudipDialog from '../../../StudipDialog.vue'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + model: { + prop: 'show', + event: 'updateShow', + }, + components: { + CompanionBox, + StudipDialog, + }, + props: { + show: { + type: Boolean, + required: true, + }, + review: { + type: Object, + required: true, + }, + }, + data: () => ({ + assessment: {}, + }), + computed: { + ...mapGetters({ + relatedProcess: 'courseware-peer-review-processes/related', + }), + assessmentComponent() { + switch (this.configuration?.type) { + case 'form': + return AssessmentTypeForm; + case 'freetext': + return AssessmentTypeFreetext; + case 'table': + return AssessmentTypeTable; + default: + return null; + } + }, + configuration() { + return this.process?.attributes?.configuration ?? {}; + }, + isActive() { + return this.process && getProcessStatus(this.process)?.status === ProcessStatus.Active; + }, + process() { + return this.relatedProcess({ + parent: { id: this.review.id, type: this.review.type }, + relationship: 'process', + }); + }, + }, + methods: { + ...mapActions({ + storeAssessment: 'tasks/storeAssessment', + }), + onAnswer(assessment) { + this.assessment = assessment; + }, + onClose() { + this.$emit('updateShow', false); + this.assessment = {}; + }, + onConfirm() { + this.$emit('updateShow', false); + this.storeAssessment({ review: this.review, assessment: this.assessment }); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditor.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditor.vue new file mode 100644 index 0000000..b017e92 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditor.vue @@ -0,0 +1,53 @@ +<template> + <component v-if="editorComponent" v-bind:is="editorComponent" v-model="payload"></component> + <CompanionBox v-else :msgCompanion="$gettext('Dieses Bewertungssystem kann nicht konfiguriert werden.')" /> +</template> + +<script> +import { mapGetters } from 'vuex'; +import CompanionBox from '../../layouts/CoursewareCompanionBox.vue'; +import EditorForm from './assessment-types/editors/EditorForm.vue'; +import EditorTable from './assessment-types/editors/EditorTable.vue'; +import { ASSESSMENT_TYPES } from './process-configuration'; + +const getPayload = (configuration) => { + const defaultPayload = ASSESSMENT_TYPES[configuration.type].defaultPayload ?? {}; + return _.isEmpty(configuration.payload) ? defaultPayload : configuration.payload; +}; + +export default { + props: { + configuration: { + type: Object, + default: () => ({}), + }, + }, + components: { CompanionBox }, + data() { + return { localPayload: _.cloneDeep(getPayload(this.configuration)) }; + }, + computed: { + ...mapGetters({}), + editorComponent() { + switch (this.configuration?.type) { + case 'form': + return EditorForm; + case 'freetext': + return null; + case 'table': + return EditorTable; + default: + return null; + } + }, + payload: { + get() { + return getPayload(this.configuration); + }, + set(payload) { + this.localPayload = payload; + }, + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue new file mode 100644 index 0000000..f6a1919 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/AssessmentTypeEditorDialog.vue @@ -0,0 +1,87 @@ +<template> + <StudipDialog + v-if="show && process" + :title="$gettext('Peer-Review-Form ändern')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="420" + width="800" + @close="onClose" + @confirm="onConfirm" + > + <template #dialogContent> + <component v-bind:is="editorComponent" v-model="payload"></component> + </template> + </StudipDialog> +</template> + +<script> +import { mapGetters } from 'vuex'; +import EditorForm from './assessment-types/editors/EditorForm.vue'; +import EditorTable from './assessment-types/editors/EditorTable.vue'; +import StudipDialog from '../../../StudipDialog.vue'; +import { ASSESSMENT_TYPES } from './process-configuration'; + +const getConfiguration = (process) => process?.attributes?.configuration ?? {}; +const getPayload = (process) => { + const configuration = getConfiguration(process); + const defaultPayload = ASSESSMENT_TYPES[configuration.type].defaultPayload ?? {}; + return _.isEmpty(configuration.payload) ? defaultPayload : configuration.payload; +}; + +export default { + model: { + prop: 'show', + event: 'updateShow', + }, + components: { + StudipDialog, + }, + props: { + show: { + type: Boolean, + required: true, + }, + process: { + type: Object, + default: null, + }, + }, + data() { + return { localPayload: _.cloneDeep(getPayload(this.process)) }; + }, + computed: { + ...mapGetters({}), + editorComponent() { + switch (getConfiguration(this.process)?.type) { + case 'form': + return EditorForm; + case 'freetext': + return null; + case 'table': + return EditorTable; + default: + return null; + } + }, + payload: { + get() { + return getPayload(this.process); + }, + set(payload) { + this.localPayload = payload; + }, + }, + }, + methods: { + onClose() { + this.$emit('updateShow', false); + }, + onConfirm(...args) { + this.$emit('update', _.cloneDeep(this.localPayload)); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/PagesProcessesIndex.vue b/resources/vue/components/courseware/tasks/peer-review/PagesProcessesIndex.vue new file mode 100644 index 0000000..10da7ed --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/PagesProcessesIndex.vue @@ -0,0 +1,225 @@ +<template> + <div class="cw-peer-review-processes-wrapper" v-if="!userIsTeacher"> + <StudipArticle> + <template #title> + {{ $gettext('Peer-Reviews von Ihnen') }} + </template> + <template #body> + <table class="default" v-if="peerReviewsGiven.length"> + <thead> + <tr> + <th>{{ $gettext('Status') }}</th> + <th>{{ $gettext('Bearbeitungszeit') }}</th> + <th>{{ $gettext('Aufgabe') }}</th> + <th>{{ $gettext('Aufgabe bearbeitet von') }}</th> + <th class="actions"> + <span class="sr-only">{{ $gettext('Aktionen') }}</span> + </th> + </tr> + </thead> + <tbody> + <tr v-for="review in peerReviewsGiven" :key="review.id"> + <td> + <ProcessStatus :process="processes[review.id]" /> + </td> + <td> + <StudipDate :date="new Date(processes[review.id].attributes['review-start'])" /> + - + <StudipDate :date="new Date(processes[review.id].attributes['review-end'])" /> + </td> + <td> + <a :href="elementUrls[review.id]"> + {{ taskGroups[review.id].attributes.title }} + </a> + </td> + <td> + {{ submitterOf(review)?.attributes['formatted-name'] ?? $gettext('anonym') }} + </td> + <td class="actions"> + <template v-if="review.attributes.assessment"> + <button class="button" @click="onShowPeerReview(review)"> + {{ $gettext('Peer-Review anzeigen') }} + </button> + </template> + <template v-else> + {{ $gettext('Kein Peer-Review abgegeben') }} + </template> + </td> + </tr> + </tbody> + </table> + <CompanionBox + v-else-if="!loading" + mood="sad" + :msgCompanion="$gettext('Sie haben noch keine Peer-Reviews gegeben.')" + /> + </template> + </StudipArticle> + <StudipArticle> + <template #title> + {{ $gettext('Peer-Reviews für Sie') }} + </template> + <template #body> + <table class="default" v-if="peerReviewsReceived.length"> + <thead> + <tr> + <th>{{ $gettext('Status') }}</th> + <th>{{ $gettext('Bearbeitungszeit') }}</th> + <th>{{ $gettext('Aufgabe') }}</th> + <th>{{ $gettext('Peer-Review von') }}</th> + <th class="actions"> + <span class="sr-only">{{ $gettext('Aktionen') }}</span> + </th> + </tr> + </thead> + <tbody> + <tr v-for="review in peerReviewsReceived" :key="review.id"> + <td> + <ProcessStatus :process="processes[review.id]" /> + </td> + <td> + <StudipDate :date="new Date(processes[review.id].attributes['review-start'])" /> + - + <StudipDate :date="new Date(processes[review.id].attributes['review-end'])" /> + </td> + <td> + <a :href="elementUrls[review.id]"> + {{ taskGroups[review.id].attributes.title }} + </a> + </td> + <td> + {{ reviewerOf(review)?.attributes['formatted-name'] ?? $gettext('anonym') }} + </td> + <td> + <template v-if="review.attributes.assessment"> + <button class="button" @click="onShowPeerReview(review)"> + {{ $gettext('Peer-Review anzeigen') }} + </button> + </template> + <template v-else> + {{ $gettext('Kein Peer-Review abgegeben') }} + </template> + </td> + </tr> + </tbody> + </table> + <CompanionBox + v-else-if="!loading" + mood="sad" + :msgCompanion="$gettext('Sie haben noch keine Peer-Reviews erhalten.')" + /> + </template> + </StudipArticle> + <ResultDialog v-model="showPeerReview" v-if="selectedPeerReview" :review="selectedPeerReview" /> + </div> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; +import CompanionBox from '../../layouts/CoursewareCompanionBox.vue'; +import ProcessStatus from './ProcessStatus.vue'; +import ResultDialog from './ResultDialog.vue'; +import StudipArticle from '../../../StudipArticle.vue'; +import StudipDate from '../../../StudipDate.vue'; +import UserAvatar from '@/vue/components/StudipUserAvatar.vue'; +import taskHelper from '../../../../mixins/courseware/task-helper.js'; + +export default { + components: { + CompanionBox, + ProcessStatus, + ResultDialog, + StudipArticle, + StudipDate, + }, + props: {}, + mixins: [taskHelper], + data: () => ({ + loading: true, + showPeerReview: false, + selectedPeerReview: null, + }), + computed: { + ...mapGetters({ + context: 'context', + currentUser: 'currentUser', + relatedPeerReviewProcesses: 'courseware-peer-review-processes/related', + relatedPeerReviews: 'courseware-peer-reviews/related', + relatedStructuralElement: 'courseware-structural-elements/related', + relatedTask: 'courseware-tasks/related', + relatedTaskGroups: 'courseware-task-groups/related', + relatedUsers: 'users/related', + userIsTeacher: 'userIsTeacher', + }), + elementUrls() { + return this.peerReviews.reduce((memo, review) => { + const task = this.tasks[review.id]; + const element = this.relatedStructuralElement({ parent: task, relationship: 'structural-element' }); + memo[review.id] = this.getLinkToElement(element); + return memo; + }, {}); + }, + peerReviews() { + const course = { type: 'courses', id: this.context.id }; + return this.relatedPeerReviews({ parent: course, relationship: 'courseware-peer-reviews' }) ?? []; + }, + peerReviewsGiven() { + return this.peerReviews.filter((peerReview) => peerReview.relationships.reviewer.data?.id === this.userId); + }, + peerReviewsReceived() { + return this.peerReviews.filter((peerReview) => peerReview.relationships.submitter.data?.id === this.userId); + }, + processes() { + return this.peerReviews.reduce((memo, review) => { + memo[review.id] = this.relatedPeerReviewProcesses({ parent: review, relationship: 'process' }); + return memo; + }, {}); + }, + taskGroups() { + return this.peerReviews.reduce((memo, review) => { + const process = this.processes[review.id]; + memo[review.id] = this.relatedTaskGroups({ parent: process, relationship: 'task-group' }); + return memo; + }, {}); + }, + tasks() { + return this.peerReviews.reduce((memo, review) => { + memo[review.id] = this.relatedTask({ parent: review, relationship: 'task' }); + return memo; + }, {}); + }, + userId() { + return this.currentUser.id; + }, + }, + methods: { + ...mapActions({ + loadRelatedPeerReviews: 'courseware-peer-reviews/loadRelated', + }), + onShowPeerReview(review) { + this.selectedPeerReview = review; + this.showPeerReview = true; + }, + reviewerOf(review) { + return this.relatedUsers({ parent: review, relationship: 'reviewer' }); + }, + submitterOf(review) { + return this.relatedUsers({ parent: review, relationship: 'submitter' }); + }, + }, + mounted() { + const parent = { type: 'courses', id: this.context.id }; + const relationship = 'courseware-peer-reviews'; + const options = { + include: 'process,task.structural-element,task.task-group,reviewer,submitter', + }; + this.loadRelatedPeerReviews({ parent, relationship, options }).then(() => (this.loading = false)); + }, +}; +</script> + +<style> +.cw-peer-review-processes-wrapper > * + * { + margin-block-start: 3rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue b/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue new file mode 100644 index 0000000..2098413 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/PairingEditor.vue @@ -0,0 +1,220 @@ +<template> + <div> + <form class="default"> + <div> + <label> + {{ $gettext('Lösung von') }} + <select v-model="selectedSubmitter" size="10"> + <option v-for="solver in selectableSubmitters" :key="solver.id" :value="solver"> + <span v-if="isUser(solver)"> + {{ solver.attributes['formatted-name'] }} + </span> + <span v-if="isStatusGroup(solver)"> + {{ solver.attributes.name }} + </span> + </option> + <option v-if="!selectableSubmitters?.length" disabled>{{ $gettext('--leer--') }}</option> + </select> + </label> + </div> + <div> + <label> + {{ $gettext('Peer-Review von') }} + <select v-model="selectedReviewer" size="10"> + <option v-for="solver in selectableReviewers" :key="solver.id" :value="solver"> + <span v-if="isUser(solver)"> + {{ solver.attributes['formatted-name'] }} + </span> + <span v-if="isStatusGroup(solver)"> + {{ solver.attributes.name }} + </span> + </option> + <option v-if="!selectableReviewers?.length" disabled>{{ $gettext('--leer--') }}</option> + </select> + </label> + </div> + <div> + <div> + <div>{{ $gettext('Paarungen') }}</div> + <div> + <button + class="button button-icon" + type="button" + :disabled="!(selectedSubmitter && selectedReviewer)" + @click="onAdd" + > + <StudipIcon shape="arr_2right" role="info_alt" /> + <StudipIcon shape="arr_2right" /> + <span class="sr-only">{{ $gettext('Hinzufügen') }}</span> + </button> + <table> + <tr v-for="({ submitter, reviewer }, index) in localPairings" :key="index"> + <td> + <span v-if="submitter.type === 'users'"> + {{ submitter.attributes['formatted-name'] }} + </span> + <span v-if="submitter.type === 'status-groups'"> + {{ submitter.attributes.name }} + </span> + </td> + + <td><span>»</span></td> + <td> + <span v-if="reviewer.type === 'users'"> + {{ reviewer.attributes['formatted-name'] }} + </span> + <span v-if="reviewer.type === 'status-groups'"> + {{ reviewer.attributes.name }} + </span> + </td> + <td> + <button @click="onTrash(index)" class="button button-icon" type="button"> + <StudipIcon shape="trash" role="info_alt" /> + <StudipIcon shape="trash" /> + <span class="sr-only">{{ $gettext('Entfernen') }}</span> + </button> + </td> + </tr> + </table> + </div> + </div> + </div> + </form> + </div> +</template> + +<script> +import _ from 'lodash'; +import { mapGetters } from 'vuex'; +import StudipIcon from '../../../StudipIcon.vue'; + +export default { + model: { + prop: 'pairings', + event: 'update', + }, + components: { StudipIcon }, + props: { + pairings: { + type: Array, + required: true, + }, + solvers: { + type: Array, + default: () => [], + }, + }, + data() { + return { + localPairings: [], + selectedSubmitter: null, + selectedReviewer: null, + }; + }, + computed: { + selectableReviewers() { + const selected = this.localPairings.map(({ reviewer }) => reviewer.id); + return this.solvers.filter(({ id }) => !selected.includes(id)); + }, + selectableSubmitters() { + const selected = this.localPairings.map(({ submitter }) => submitter.id); + return this.solvers.filter(({ id }) => !selected.includes(id)); + }, + }, + methods: { + isStatusGroup(object) { + return object.type === 'status-groups'; + }, + isUser(object) { + return object.type === 'users'; + }, + onAdd() { + this.localPairings.push({ + reviewer: this.selectedReviewer, + submitter: this.selectedSubmitter, + }); + this.selectedReviewer = null; + this.selectedSubmitter = null; + }, + onTrash(index) { + this.localPairings = [...this.localPairings.slice(0, index), ...this.localPairings.slice(index + 1)]; + }, + resetLocalState() { + this.localPairings = [...this.pairings]; + }, + }, + mounted() { + this.resetLocalState(); + }, + watch: { + localPairings(newP, oldP) { + if (!_.isEqual(this.localPairings, this.pairings)) { + this.$emit('update', [...this.localPairings]); + } + }, + pairings() { + if (!_.isEqual(this.localPairings, this.pairings)) { + this.resetLocalState(); + } + }, + selectedReviewer() { + if (this.selectedReviewer === this.selectedSubmitter) { + this.selectedSubmitter = null; + } + }, + selectedSubmitter() { + if (this.selectedReviewer === this.selectedSubmitter) { + this.selectedReviewer = null; + } + }, + }, +}; +</script> + +<style scoped> +form { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +form > * { + flex-grow: 1; +} + +form > :nth-child(1) select, +form > :nth-child(2) select { + max-width: 15rem; +} + +form > :nth-child(3) { + flex-basis: 15rem; +} + +tr > :nth-child(2), +tr > :nth-child(4) { + padding-inline: 0.5rem; +} + +button.button-icon { + min-width: auto; + line-height: 1.5rem; + padding: 0; + width: 1.5rem; +} +button.button-icon > img { + vertical-align: middle; +} +button.button-icon > img:first-child { + display: none; +} +button.button-icon:hover > img:first-child { + display: inline; +} +button.button-icon > img:first-child { + display: hidden; +} +button.button-icon:hover > img:nth-child(2) { + display: none; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue b/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue new file mode 100644 index 0000000..95e1b4a --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/PairingEditorDialog.vue @@ -0,0 +1,88 @@ +<template> + <StudipDialog + v-if="show && process" + :title="$gettext('Zuordnungen festlegen')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="800" + width="800" + @close="onClose" + @confirm="onConfirm" + > + <template #dialogContent> + <PairingEditor v-if="pairings" v-model="pairings" :solvers="solvers" /> + </template> + </StudipDialog> +</template> + +<script> +import { mapGetters } from 'vuex'; +import PairingEditor from './PairingEditor.vue'; +import StudipDialog from '../../../StudipDialog.vue'; + +const objId = ({ id, type }) => ({ id, type }); + +export default { + model: { + prop: 'show', + event: 'updateShow', + }, + components: { + PairingEditor, + StudipDialog, + }, + props: { + show: { + type: Boolean, + required: true, + }, + process: { + type: Object, + default: null, + }, + }, + data() { + return { + pairings: [], + }; + }, + computed: { + ...mapGetters({ + relatedPeerReviews: 'courseware-peer-reviews/related', + relatedTaskGroups: 'courseware-task-groups/related', + }), + reviewPairs() { + return this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' }).map((review) => ({ + reviewer: this.getObject(review.relationships.reviewer.data), + submitter: this.getObject(review.relationships.submitter.data), + })); + }, + solvers() { + return this.taskGroup.relationships.solvers.data.map((solver) => this.getObject(solver)); + }, + taskGroup() { + return this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' }); + }, + }, + methods: { + getObject({ type, id }) { + return this.$store.getters[`${type}/byId`]({ id }); + }, + onClose() { + this.$emit('updateShow', false); + }, + onConfirm() { + this.$emit('update', this.pairings); + }, + }, + watch: { + show() { + if (this.show) { + this.pairings = this.reviewPairs; + } + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue b/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue new file mode 100644 index 0000000..4fc79b6 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewList.vue @@ -0,0 +1,50 @@ +<template> + <div v-if="peerReviews && peerReviews.length > 0"> + <table class="default"> + <thead> + <tr> + <th>{{ $gettext("Aufgabe") }}</th> + <th>{{ $gettext("Lösung von") }}</th> + <th>{{ $gettext("Peer-Review von") }}</th> + <th> </th> + </tr> + </thead> + <tbody> + <PeerReviewListItem + v-for="review in peerReviews" + :review="review" + :key="review.id" + :process="process" + :task-group="taskGroup" + /> + </tbody> + </table> + </div> +</template> + +<script> +import { mapGetters } from 'vuex'; +import PeerReviewListItem from './PeerReviewListItem.vue'; + +export default { + components: { PeerReviewListItem }, + props: { + process: { + type: Object, + required: true, + }, + taskGroup: { + type: Object, + required: true, + }, + }, + computed: { + ...mapGetters({ + relatedPeerReviews: 'courseware-peer-reviews/related', + }), + peerReviews() { + return this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' }); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue b/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue new file mode 100644 index 0000000..ca27889 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/PeerReviewListItem.vue @@ -0,0 +1,120 @@ +<template> + <tr> + <td> + <a :href="getLinkToElement(element)"> + {{ taskGroup.attributes.title }} + </a> + </td> + <td> + <a v-if="isUser(submitter)" :href="userProfile(submitter)"> + <UserAvatar + :avatar-url="submitter.meta.avatar.small" + :formatted-name="submitter.attributes['formatted-name']" + small + /> + </a> + <a v-else :href="statusGroupUrl(submitter)"> + {{ submitter.attributes.name }} + </a> + </td> + <td> + <a v-if="isUser(reviewer)" :href="userProfile(reviewer)"> + <UserAvatar + :avatar-url="reviewer.meta.avatar.small" + :formatted-name="reviewer.attributes['formatted-name']" + small + /> + </a> + <a v-else :href="statusGroupUrl(reviewer)"> + {{ reviewer.attributes.name }} + </a> + </td> + <td> + <button class="button" @click="onShowAssessment" :disabled="canShowReview">{{ $gettext('Peer-Review anzeigen') }}</button> + </td> + </tr> +</template> + +<script> +import { mapGetters } from 'vuex'; +import UserAvatar from '@/vue/components/StudipUserAvatar.vue'; +import taskHelper from '../../../../mixins/courseware/task-helper.js'; +import { getProcessStatus, ProcessStatus } from './definitions'; + +export default { + mixins: [taskHelper], + props: { + process: { + type: Object, + required: true, + }, + review: { + type: Object, + required: true, + }, + taskGroup: { + type: Object, + required: true, + }, + }, + components: { UserAvatar }, + computed: { + ...mapGetters({ + context: 'context', + relatedStructuralElement: 'courseware-structural-elements/related', + relatedTasks: 'courseware-tasks/related', + relatedStatusGroups: 'status-groups/related', + relatedUsers: 'users/related', + }), + canShowReview() { + return getProcessStatus(this.process).status !== ProcessStatus.After; + }, + element() { + const parent = { id: this.task.id, type: this.task.type }; + const relationship = 'structural-element'; + return this.relatedStructuralElement({ parent, relationship }); + }, + reviewer() { + const user = this.relatedUsers({ parent: this.review, relationship: 'reviewer' }); + if (user) { + return user; + } + const statusGroup = this.relatedStatusGroups({ parent: this.review, relationship: 'reviewer' }); + return statusGroup; + }, + submitter() { + const user = this.relatedUsers({ parent: this.task, relationship: 'solver' }); + if (user) { + return user; + } + const statusGroup = this.relatedStatusGroups({ parent: this.task, relationship: 'solver' }); + return statusGroup; + }, + task() { + const parent = { id: this.review.id, type: this.review.type }; + const relationship = 'task'; + return this.relatedTasks({ parent, relationship }); + }, + }, + methods: { + isUser(object) { + return object.type === 'users'; + }, + onShowAssessment() { + console.debug('NYI'); + }, + statusGroupUrl(statusGroup) { + const cid = this.context.id; + return window.STUDIP.URLHelper.getURL( + 'dispatch.php/course/statusgroups', + { cid, contentbox_open: statusGroup.id }, + true + ); + }, + userProfile(user) { + const username = user.attributes.username; + return window.STUDIP.URLHelper.getURL('dispatch.php/profile', { username }, true); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue new file mode 100644 index 0000000..6377528 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessConfiguration.vue @@ -0,0 +1,40 @@ +<template> + <ul> + <li v-if="options.anonymous">{{ $gettext('Anonymes Review') }}</li> + <li v-else>{{ $gettext('Offenes Review') }}</li> + + <li> + {{ + $gettextInterpolate($gettext('%{n} Tage Zeit für das Review'), { + n: options.duration, + }) + }} + </li> + + <li> + {{ reviewTypes[options.type].long }} + </li> + + <li v-if="options.automaticPairing"> + {{ $gettext('Zusammenstellung der Review-Paarungen durch das Programm') }} + </li> + <li v-else>{{ $gettext('Zusammenstellung der Review-Paarungen durch die Lehrenden') }}</li> + </ul> +</template> + +<script lang="ts"> +import Vue, { PropType } from 'vue'; +import { ProcessConfiguration, ASSESSMENT_TYPES } from './process-configuration'; + +export default Vue.extend({ + props: { + options: { + required: true, + type: Object as PropType<ProcessConfiguration>, + }, + }, + computed: { + reviewTypes: () => ASSESSMENT_TYPES, + }, +}); +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue new file mode 100644 index 0000000..0ddc9b8 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateDialog.vue @@ -0,0 +1,75 @@ +<template> + <WizardDialog + :title="$gettext('Peer-Review-Prozess anlegen')" + :confirmText="$gettext('Anlegen')" + :closeText="$gettext('Abbrechen')" + @close="$emit('close')" + @confirm="create" + height="800" + width="800" + :lastRequiredSlotId="0" + :requirements="requirements" + :slots="wizardSlots" + > + <template v-slot:configuration> + <ProcessCreateForm :configuration="configuration" @update="updateConfiguration" /> + </template> + + <template v-slot:assessment> + <AssessmentTypeEditor :configuration="configuration" /> + </template> + </WizardDialog> +</template> + +<script> +import AssessmentTypeEditor from './AssessmentTypeEditor.vue'; +import ProcessCreateForm from './ProcessCreateForm.vue'; +import WizardDialog from '../../../StudipWizardDialog.vue'; +import { defaultConfiguration, ProcessConfiguration } from './process-configuration'; +import { $gettext, $gettextInterpolate } from '../../../../../assets/javascripts/lib/gettext'; + +const getSlots = () => { + return [ + { + id: 1, + valid: true, + name: 'configuration', + title: $gettext('Konfiguration'), + icon: 'courseware', + description: $gettext( + 'Es gibt im Moment in diese Mannschaft, oh, einige Spieler vergessen ihnen Profi was sie sind. Ich lese nicht sehr viele Zeitungen, aber ich habe gehört viele Situationen. Erstens: wir haben nicht offensiv gespielt.' + ), + }, + { + id: 2, + valid: true, + name: 'assessment', + title: $gettext('Bewertungssystem'), + icon: 'content2', + description: $gettext( + 'Es gibt keine deutsche Mannschaft spielt offensiv und die Name offensiv wie Bayern. Letzte Spiel hatten wir in Platz drei Spitzen: Elber, Jancka und dann Zickler. Wir müssen nicht vergessen Zickler. Zickler ist eine Spitzen mehr, Mehmet eh mehr Basler.' + ), + }, + ]; +}; + +export default { + components: { AssessmentTypeEditor, ProcessCreateForm, WizardDialog }, + props: ['taskGroup'], + data: () => ({ + changed: false, + configuration: defaultConfiguration(), + requirements: [], + wizardSlots: getSlots(), + }), + methods: { + create() { + this.$emit('create', { ...this.configuration }); + }, + updateConfiguration(configuration) { + this.changed = true; + this.configuration = configuration; + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue new file mode 100644 index 0000000..dcdae6e --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessCreateForm.vue @@ -0,0 +1,266 @@ +<template> + <form class="default" @submit.prevent=""> + <div class="peer-review-process-create-form-switcher"> + <button + class="button" + :class="{ active: !showCustomConfiguration }" + @click="showCustomConfiguration = false" + > + {{ $gettext('Einfach') }} + </button> + <button class="button" :class="{ active: showCustomConfiguration }" @click="showCustomConfiguration = true"> + {{ $gettext('Erweitert') }} + </button> + </div> + + <section class="peer-review-process-create-form-type-cards" v-if="!showCustomConfiguration"> + <article + v-for="(configurationSet, index) in configurationSets" + :key="index" + :class="{ selected: selectedConfigurationSet === index }" + > + <h2>{{ configurationSet.name }}</h2> + + <button + class="button" + :class="{ accept: selectedConfigurationSet === index }" + :disabled="selectedConfigurationSet === index" + type="button" + @click="selectConfigurationSet(index)" + > + {{ selectedConfigurationSet === index ? $gettext('Ausgewählt') : $gettext('Auswählen') }} + </button> + + <PeerReviewProcessConfiguration :options="configurationSet.configuration" /> + </article> + </section> + + <ContentBox + v-else + class="peer-review-process-create-form-custom-configuration" + :title="$gettext('Erweiterte Einstellungen')" + > + <div class="custom-configuration"> + <div class="formpart"> + <LabelRequired + :id="`peer-review-process-create-form-${uid}-anonymous`" + :label="$gettext('Anonymes oder offenes Review:')" + > + <select + v-model="localConfiguration.anonymous" + :id="`peer-review-process-create-form-${uid}-anonymous`" + @change="customizeConfiguration" + > + <option :value="true">{{ $gettext('anonym') }}</option> + <option :value="false">{{ $gettext('offen') }}</option> + </select> + </LabelRequired> + </div> + + <div class="formpart"> + <LabelRequired + :id="`peer-review-process-create-form-${uid}-duration`" + :label="$gettext('Bearbeitungszeitraum in Tagen:')" + > + <select + v-model.number="localConfiguration.duration" + :id="`peer-review-process-create-form-${uid}-duration`" + @change="customizeConfiguration" + > + <option v-for="i in 21" :key="i">{{ i }}</option> + </select> + </LabelRequired> + </div> + + <div class="formpart"> + <LabelRequired + :id="`peer-review-process-create-form-${uid}-type`" + :label="$gettext('Art des Reviews:')" + > + <select + v-model="localConfiguration.type" + :id="`peer-review-process-create-form-${uid}-type`" + @change="onChangeType" + > + <option v-for="[key, { short }] in Object.entries(reviewTypes)" :key="key" :value="key"> + {{ short }} + </option> + </select> + </LabelRequired> + </div> + + <div class="formpart"> + <LabelRequired + :id="`peer-true-process-create-form-${uid}-anonymous`" + :label="$gettext('Review-Paarungen')" + > + <select + v-model="localConfiguration.automaticPairing" + :id="`peer-review-process-create-form-${uid}-automatic-pairing`" + @change="customizeConfiguration" + > + <option :value="true">{{ $gettext('Zufall') }}</option> + <option :value="false">{{ $gettext('Manuell') }}</option> + </select> + </LabelRequired> + </div> + </div> + </ContentBox> + </form> +</template> + +<script> +import ContentBox from '../../../StudipContentBox.vue'; +import LabelRequired from '../../../forms/LabelRequired.vue'; +import PeerReviewProcessConfiguration from './ProcessConfiguration.vue'; +import { ASSESSMENT_TYPES, CONFIGURATION_SETS, ProcessConfiguration } from './process-configuration'; + +let nextId = 0; + +export default { + components: { ContentBox, LabelRequired, PeerReviewProcessConfiguration }, + props: { + configuration: { + required: true, + type: Object, + }, + custom: { + type: Boolean, + default: false, + }, + }, + data() { + return { + localConfiguration: { ...this.configuration }, + selectedConfigurationSet: -1, + showCustomConfiguration: this.custom, + uid: nextId++, + }; + }, + computed: { + reviewTypes: () => ASSESSMENT_TYPES, + configurationSets: () => CONFIGURATION_SETS, + }, + methods: { + customizeConfiguration() { + this.selectedConfigurationSet = -1; + this.update(); + }, + findSelectedConfigurationSet() { + this.selectedConfigurationSet = this.configurationSets.findIndex(({ configuration }) => + _.isEqual(this.configuration, configuration) + ); + }, + onChangeType() { + this.localConfiguration.payload = + this.localConfiguration.type === this.configuration.type + ? this.configuration.payload + : ASSESSMENT_TYPES[this.localConfiguration.type].defaultPayload; + this.customizeConfiguration(); + }, + resetData() { + this.localConfiguration = { ...this.configuration }; + this.findSelectedConfigurationSet(); + }, + selectConfigurationSet(configurationSetIndex) { + this.selectedConfigurationSet = configurationSetIndex; + this.localConfiguration = CONFIGURATION_SETS[configurationSetIndex].configuration; + this.update(); + }, + update() { + this.$emit('update', this.localConfiguration); + }, + }, + mounted() { + this.findSelectedConfigurationSet(); + }, + watch: { + configuration() { + this.resetData(); + }, + }, +}; +</script> + +<style scoped lang="scss"> +.peer-review-process-create-form-type-cards { + box-sizing: border-box; + width: 100%; + margin-block: 1.5rem 0; + + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + --threshold: 45rem; + + article { + flex-grow: 1; + flex-basis: calc((var(--threshold) - 100%) * 999); + box-sizing: border-box; + padding: 1rem; + border: 2px var(--dark-gray-color-20) solid; + + &.selected { + border-color: var(--dark-gray-color-80); + border-width: 2px; + } + + h2 { + font-weight: bold; + font-size: 1.2rem; + margin-block: 1rem 0; + } + button { + margin-block: 1.5rem; + } + ul { + padding-inline: 1em 0; + } + li { + padding-block: 0.5rem; + } + } + + > :nth-last-child(n + 4), + > :nth-last-child(n + 4) ~ * { + flex-basis: 100%; + } +} + +.peer-review-process-create-form-type-cards + section { + text-align: center; + margin-block-end: 1.5rem; +} + +.peer-review-process-create-form-custom-configuration { + margin-block: 1.5rem; +} + +.custom-configuration { + padding: 1rem; +} + +.peer-review-process-create-form-switcher { + display: flex; + justify-content: center; +} + +.peer-review-process-create-form-switcher button { + margin: 0; +} + +.peer-review-process-create-form-switcher button + button { + border-left: none; +} + +.peer-review-process-create-form-switcher button.active { + background: var(--base-color); + color: var(--white); + cursor: default; +} + +.peer-review-process-create-form-switcher button:not(.active):hover { + background: var(--white); + color: var(--base-color); +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessDetail.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessDetail.vue new file mode 100644 index 0000000..d44ea33 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessDetail.vue @@ -0,0 +1,206 @@ +<template> + <div> + <CompanionBox + v-if="isActive" + :msgCompanion=" + $gettext( + 'Der Peer-Review-Prozess hat bereits begonnen. Die Einstellungen können Bis auf die Bearbeitungsdauer nicht geändert werden.' + ) + " + /> + + <section> + <article> + <header> + <h4>{{ $gettext('Status') }}</h4> + </header> + <div class="cw-peer-review-processes-status"> + <ProcessStatus :process="process" /> + <span>{{ processStatus.description }}</span> + </div> + <div> + <span>{{ $gettext('Bearbeitungszeit:') }}</span> + <StudipDate :date="startDate" />–<StudipDate :date="endDate" /> + </div> + <div v-if="canChangeDurationOnly"> + <button class="button" @click="$emit('change-peer-review-process-duration')"> + {{ $gettext('Bearbeitungszeit verlängern') }} + </button> + </div> + </article> + <article> + <header> + <h4>{{ $gettext('Einstellungen') }}</h4> + </header> + <div> + <ProcessConfiguration :options="configuration" /> + </div> + <div> + <button + class="button" + @click="$emit('edit-peer-review-process')" + :disabled="!canChangeConfiguration" + > + {{ $gettext('Einstellungen ändern') }} + </button> + <button + v-if="configuration.type === 'form' || configuration.type === 'table'" + class="button" + @click="$emit('show-assessment-type-editor')" + :disabled="!canChangeConfiguration" + > + {{ $gettext('Bewertungssystem konfigurieren') }} + </button> + </div> + </article> + + <article> + <header> + <h4>{{ $gettext('Peer-Reviews') }}</h4> + </header> + <div> + <template v-if="isBefore"> + <CompanionBox + v-if="isAutomaticPairing" + :msgCompanion=" + $gettext( + 'In diesem Peer-Review-Prozess werden die Paarungen automatisch verteilt, sobald der Bearbeitungszeitraum beginnt.' + ) + " + > + </CompanionBox> + <CompanionBox + v-else + mood="pointing" + :msgCompanion=" + $gettext( + 'In diesem Peer-Review-Prozess werden die Paarungen manuell verteilt, bevor der Bearbeitungszeitraum beginnt.' + ) + " + > + <template #companionActions> + <button class="button" @click="$emit('show-pairing-editor')"> + {{ $gettext('Paarungen manuell festlegen') }} + </button> + </template> + </CompanionBox> + </template> + <PeerReviewList :process="process" :task-group="taskGroup" /> + </div> + </article> + </section> + </div> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex'; +import CompanionBox from '../../layouts/CoursewareCompanionBox.vue'; +import PeerReviewList from './PeerReviewList.vue'; +import ProcessConfiguration from './ProcessConfiguration.vue'; +import ProcessStatus from './ProcessStatus.vue'; +import StudipDate from '../../../StudipDate.vue'; +import { getProcessStatus, ProcessStatus as Status } from './definitions'; + +export default { + components: { + CompanionBox, + PeerReviewList, + ProcessConfiguration, + ProcessStatus, + StudipDate, + }, + props: { + process: { + type: Object, + required: true, + }, + }, + emits: [ + 'show-assessment-type-editor', + 'show-pairing-editor', + 'change-peer-review-process-duration', + 'edit-peer-review-process', + ], + data: () => ({}), + computed: { + ...mapGetters({ + getProcess: 'courseware-peer-review-processes/byId', + relatedPeerReviews: 'courseware-peer-reviews/related', + relatedTasks: 'courseware-tasks/related', + relatedTaskGroups: 'courseware-task-groups/related', + relatedUsers: 'users/related', + userIsTeacher: 'userIsTeacher', + }), + canChangeConfiguration() { + return this.isBefore; + }, + canChangeDurationOnly() { + return this.processStatus.status === Status.Active; + }, + configuration() { + return this.process.attributes['configuration']; + }, + endDate() { + return new Date(this.process.attributes['review-end']); + }, + isActive() { + return this.processStatus.status === Status.Active; + }, + isAfter() { + return this.processStatus.status === Status.After; + }, + isBefore() { + return this.processStatus.status === Status.Before; + }, + isAutomaticPairing() { + return this.configuration.automaticPairing; + }, + owner() { + return this.relatedUsers({ parent: this.process, relationship: 'owner' }); + }, + peerReviews() { + const result = this.relatedPeerReviews({ parent: this.process, relationship: 'peer-reviews' }); + return result; + }, + processStatus() { + return getProcessStatus(this.process); + }, + solvers() { + return this.taskGroup.relationships.solvers.data.map(({ id, type }) => { + return [id, type]; + }); + }, + startDate() { + return new Date(this.process.attributes['review-start']); + }, + taskGroup() { + return this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' }); + }, + tasks() { + return this.relatedTasks({ parent: this.taskGroup, relationship: 'tasks' }); + }, + }, + methods: { + ...mapActions({ + loadRelatedPeerReviews: 'courseware-peer-reviews/loadRelated', + }), + loadPeerReviews() { + return this.loadRelatedPeerReviews({ + parent: this.process, + relationship: 'peer-reviews', + options: { include: 'reviewer,task' }, + }); + }, + }, + async mounted() { + await this.loadPeerReviews(); + }, +}; +</script> + +<style> +.cw-peer-review-processes-status { + display: flex; + gap: 0.25rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue new file mode 100644 index 0000000..af6756f --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessDurationDialog.vue @@ -0,0 +1,122 @@ +<template> + <StudipDialog + v-if="show && process" + :title="$gettext('Bearbeitungszeit ändern')" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :closeText="$gettext('Schließen')" + closeClass="cancel" + @close="onClose" + @confirm="onConfirm" + > + <template #dialogContent> + <form class="default"> + <p> + {{ $gettext('Aktuelle Bearbeitungszeit:') }} <StudipDate :date="startDate" />–<StudipDate :date="endDate" /> + ({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: oldDuration }) }}) + </p> + <div class="formpart"> + <LabelRequired + :id="`peer-review-process-${uid}`" + :label="$gettext('Bearbeitungszeit verlängern bis zum:')" + /> + <input + :id="`peer-review-process-${uid}`" + name="end-date" + type="date" + v-model="localEndDate" + :min="endDateString" + class="size-l" + required + /> + <div>({{ $gettextInterpolate($gettext('%{ count } Tage'), { count: newDuration }) }})</div> + </div> + </form> + </template> + </StudipDialog> +</template> + +<script> +import { mapGetters } from 'vuex'; +import LabelRequired from '../../../forms/LabelRequired.vue'; +import StudipDate from '../../../StudipDate.vue'; +import StudipDialog from '../../../StudipDialog.vue'; + +const midnight = (_date) => { + const date = new Date(_date); + date.setHours(0); + date.setMinutes(0); + date.setSeconds(0); + date.setMilliseconds(0); + return date; +}; + +const dateString = (date) => + `${date.getFullYear()}-${('' + (date.getMonth() + 1)).padStart(2, '0')}-${('' + date.getDate()).padStart(2, '0')}`; + +let nextUid = 0; + +export default { + model: { + prop: 'show', + event: 'updateShow', + }, + components: { + LabelRequired, + StudipDate, + StudipDialog, + }, + props: { + show: { + type: Boolean, + required: true, + }, + process: { + type: Object, + default: null, + }, + }, + data: () => ({ localEndDate: null, uid: nextUid++ }), + computed: { + configuration() { + return this.process?.attributes?.configuration ?? {}; + }, + endDate() { + return midnight(this.process?.attributes?.['review-end'] ?? new Date()); + }, + endDateString() { + return dateString(this.endDate); + }, + newDuration() { + return this.localEndDate + ? Math.floor((midnight(this.localEndDate) - midnight(this.startDate)) / (1000 * 60 * 60 * 24)) + : 0; + }, + oldDuration() { + return this.configuration.duration ?? '??'; + }, + startDate() { + return midnight(this.process.attributes['review-start']); + }, + }, + methods: { + onClose() { + this.$emit('updateShow', false); + }, + onConfirm(...args) { + this.$emit('update', this.newDuration); + }, + resetLocalVars() { + this.localEndDate = dateString(this.endDate ?? new Date()); + }, + }, + mounted() { + this.resetLocalVars(); + }, + watch: { + process() { + this.resetLocalVars(); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue new file mode 100644 index 0000000..56a8cb7 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessEditDialog.vue @@ -0,0 +1,65 @@ +<template> + <StudipDialog + :title="title" + :confirmText="$gettext('Speichern')" + confirmClass="accept" + :confirmDisabled="!changed" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="600" + width="800" + @close="$emit('close')" + @confirm="confirm" + > + <template #dialogContent> + <ProcessCreateForm :configuration="process.attributes.configuration" custom @update="updateConfiguration" /> + </template> + </StudipDialog> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { mapGetters } from 'vuex'; +import { $gettext, $gettextInterpolate } from '../../../../../assets/javascripts/lib/gettext'; +import StudipDialog from '../../../StudipDialog.vue'; +import ProcessCreateForm from './ProcessCreateForm.vue'; +import { defaultConfiguration, ProcessConfiguration } from './process-configuration'; + +export default Vue.extend({ + components: { ProcessCreateForm, StudipDialog }, + props: ['process'], + data: () => ({ + changed: false, + configuration: defaultConfiguration(), + }), + computed: { + ...mapGetters({ + relatedTaskGroups: 'courseware-task-groups/related', + }), + title() { + const taskGroup = this.relatedTaskGroups({ parent: this.process, relationship: 'task-group' }); + return $gettextInterpolate($gettext('Peer-Review-Prozess konfigurieren zur Aufgabe "%{title}"'), { + title: taskGroup.attributes.title, + }); + }, + }, + methods: { + confirm() { + this.$emit('update', { + process: this.process, + configuration: { ...this.configuration }, + }); + }, + updateConfiguration(configuration: ProcessConfiguration) { + this.changed = true; + this.configuration = configuration; + }, + }, +}); +</script> + +<style scoped> +header { + margin-block-end: 2rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue b/resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue new file mode 100644 index 0000000..666514d --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ProcessStatus.vue @@ -0,0 +1,47 @@ +<template> + <span class="peer-review-process-status" v-if="!filter || status.status === filter"> + <StudipIcon + v-if="status.shape !== undefined" + :shape="status.shape" + :role="status.role" + :title="status.description" + aria-hidden="true" + /> + <span :class="{'sr-only': !description }">{{ status.description }}</span> + </span> +</template> +<script> +import StudipIcon from '../../../StudipIcon.vue'; +import { getProcessStatus, ProcessStatus } from './definitions'; + +export default { + components: { StudipIcon }, + props: { + description: { + type: Boolean, + default: false, + }, + filter: { + type: String, + default: null, + }, + process: { + type: Object, + required: true, + }, + }, + computed: { + status() { + return getProcessStatus(this.process); + }, + }, +}; +</script> + +<style scoped> +.peer-review-process-status { + display: flex; + align-items: center; + gap: 0.5rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/ResultDialog.vue b/resources/vue/components/courseware/tasks/peer-review/ResultDialog.vue new file mode 100644 index 0000000..344352b --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/ResultDialog.vue @@ -0,0 +1,77 @@ +<template> + <StudipDialog + v-if="show" + :title="$gettext('Peer-Review ansehen')" + :closeText="$gettext('Schließen')" + closeClass="cancel" + height="700" + width="700" + @close="onClose" + > + <template #dialogContent> + <component v-bind:is="assessmentComponent" :process="process" :review="review"></component> + </template> + </StudipDialog> +</template> + +<script> +import ResultForm from './assessment-types/results/Form.vue'; +import ResultFreetext from './assessment-types/results/Freetext.vue'; +import ResultTable from './assessment-types/results/Table.vue'; +import StudipDialog from '../../../StudipDialog.vue'; +import { mapActions, mapGetters } from 'vuex'; + +export default { + model: { + prop: 'show', + event: 'updateShow', + }, + components: { + StudipDialog, + }, + props: { + show: { + type: Boolean, + required: true, + }, + review: { + type: Object, + required: true, + }, + }, + computed: { + ...mapGetters({ + relatedProcess: 'courseware-peer-review-processes/related', + }), + assessmentComponent() { + switch (this.configuration?.type) { + case 'form': + return ResultForm; + case 'freetext': + return ResultFreetext; + case 'table': + return ResultTable; + default: + return null; + } + }, + configuration() { + return this.process?.attributes?.configuration ?? {}; + }, + process() { + return this.relatedProcess({ + parent: { id: this.review.id, type: this.review.type }, + relationship: 'process', + }); + }, + }, + methods: { + onClose() { + this.$emit('updateShow', false); + }, + onConfirm() { + this.$emit('updateShow', false); + }, + }, +}; +</script> diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorForm.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorForm.vue new file mode 100644 index 0000000..d7de861 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorForm.vue @@ -0,0 +1,153 @@ +<template> + <CoursewareTabs> + <CoursewareTab :name="$gettext('Editor')" :index="0" selected class="cw-peer-review-editor-form--editor"> + <form class="default studipform"> + <StudipArticle v-for="(criterium, index) in localCriteria" :key="index" collapsable> + <template v-slot:title="{ isOpen }"> + <template v-if="isOpen"> + {{ + $gettextInterpolate($gettext('Kriterium %{ index }: "%{ text }"'), { + index: index + 1, + text: criterium.text, + }) + }} + </template> + <template v-else> + {{ $gettextInterpolate($gettext('Kriterium %{ index }'), { index: index + 1 }) }} + </template> + </template> + <template #titleplus> + <StudipActionMenu :items="actionItems(index)" :collapseAt="2" @trash="removeLine" /> + </template> + <template #body> + <div class="formpart criterium-text"> + <LabelRequired :id="`editor-form-text-${index}`" :label="$gettext('Kriterium')" /> + <input + :id="`editor-form-text-${index}`" + type="text" + v-model="criterium.text" + required + aria-required="true" + /> + </div> + <div class="formpart criterium-description"> + <LabelRequired :id="`editor-form-description-${index}`" :label="$gettext('Beschreibung')" /> + <textarea + :id="`editor-form-description-${index}`" + v-model="criterium.description" + required + aria-required="true" + ></textarea> + </div> + </template> + </StudipArticle> + <div class="formpart"> + <button class="button add" type="button" @click="addLine"> + <span>{{ $gettext('Kriterium hinzufügen') }}</span> + </button> + </div> + </form> + </CoursewareTab> + <CoursewareTab :name="$gettext('Vorschau')" :index="1" class="cw-peer-review-editor-form--preview"> + <article> + <section v-for="(criterium, index) in nonEmptyCriteria" :key="index"> + <strong>{{ criterium.text }}</strong> + <p>{{ criterium.description }}</p> + <textarea disabled /> + </section> + </article> + </CoursewareTab> + </CoursewareTabs> +</template> +<script lang="ts"> +import Vue, { PropType } from 'vue'; +import StudipActionMenu from '../../../../../StudipActionMenu.vue'; +import StudipArticle from '../../../../../StudipArticle.vue'; +import LabelRequired from '../../../../../forms/LabelRequired.vue'; +import CoursewareTab from '../../../../layouts/CoursewareTab.vue'; +import CoursewareTabs from '../../../../layouts/CoursewareTabs.vue'; +import { EditorFormCriterium, FormAssessmentPayload } from '../../process-configuration'; + +export default Vue.extend({ + components: { CoursewareTab, CoursewareTabs, LabelRequired, StudipActionMenu, StudipArticle }, + props: { + payload: { + type: Object as PropType<FormAssessmentPayload>, + }, + }, + model: { + prop: 'payload', + event: 'save', + }, + data: () => ({ localCriteria: [] as EditorFormCriterium[] }), + computed: { + criteria() { + return this.payload.criteria; + }, + nonEmptyCriteria() { + return this.localCriteria.filter(({ text }) => text.trim().length); + }, + }, + methods: { + actionItems(index: number) { + return this.localCriteria.length > 1 + ? [ + { + id: 1, + label: this.$gettext('Kriterium entfernen'), + icon: 'trash', + emit: 'trash', + emitArguments: [index], + }, + ] + : []; + }, + addLine() { + this.localCriteria.push({ text: '', description: '' }); + }, + removeLine(lineNumber: number) { + this.localCriteria = this.localCriteria.filter((item, index) => index !== lineNumber); + }, + resetLocalState() { + this.localCriteria = this.criteria.map(({ text, description }) => ({ text, description })); + }, + }, + mounted() { + this.resetLocalState(); + }, + watch: { + payload() { + this.resetLocalState(); + }, + localCriteria: { + handler() { + this.$emit('save', { criteria: this.nonEmptyCriteria.map((c) => ({ ...c })) }); + }, + deep: true, + }, + }, +}); +</script> + +<style scoped> +.cw-peer-review-editor-form--editor form input { + max-width: 48em; +} + +textarea { + min-height: 5em; + max-width: 48em; + width: 100%; +} + +.cw-peer-review-editor-form--preview > article { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.cw-peer-review-editor-form--preview > article > * + * { + border-top: 1px solid var(--light-gray-color-40); + padding-block-start: 1rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorTable.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorTable.vue new file mode 100644 index 0000000..7ce6d7a --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/editors/EditorTable.vue @@ -0,0 +1,132 @@ +<template> + <CoursewareTabs> + <CoursewareTab :name="$gettext('Editor')" :index="0" selected class="cw-peer-review-editor-table-editor"> + <form class="studip studipform"> + <div class="formpart" v-for="(criterium, index) in localCriteria" :key="index"> + <LabelRequired :id="`editor-table-text-${index}`" :label="$gettext('Kriterium')" class="sr-only" /> + <input + :id="`editor-table-text-${index}`" + type="text" + v-model="criterium.text" + required + aria-required="true" + /> + <button + class="button trash" + type="button" + @click="removeLine(index)" + :disabled="criteria.length === 1" + > + <span class="sr-only">{{ $gettext('Kriterium entfernen') }}</span> + </button> + </div> + <div class="formpart"> + <button class="button add" type="button" @click="addLine"> + <span>{{ $gettext('Kriterium hinzufügen') }}</span> + </button> + </div> + </form> + </CoursewareTab> + <CoursewareTab :name="$gettext('Vorschau')" :index="1" class="cw-peer-review-editor-table--preview"> + <table class="default"> + <thead> + <tr> + <th>{{ $gettext('Kriterien') }}</th> + <th>{{ $gettext('Bewertung') }}</th> + <th>{{ $gettext('Kommentar') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="(criterium, index) in nonEmptyCriteria" :key="index"> + <td>{{ criterium.text }}</td> + <td> + <label v-for="text in [$gettext('gut'), $gettext('ok'), $gettext('schwach')]" :key="text"> + <input name="rating" type="radio" disabled /> + {{ text }} + </label> + </td> + <td> + <textarea disabled /> + </td> + </tr> + </tbody> + </table> + </CoursewareTab> + </CoursewareTabs> +</template> +<script lang="ts"> +import Vue, { PropType } from 'vue'; +import LabelRequired from '../../../../../forms/LabelRequired.vue'; +import CoursewareTab from '../../../../layouts/CoursewareTab.vue'; +import CoursewareTabs from '../../../../layouts/CoursewareTabs.vue'; +import { EditorTableCriterium, TableAssessmentPayload } from '../../process-configuration'; + +export default Vue.extend({ + components: { CoursewareTab, CoursewareTabs, LabelRequired }, + props: { + payload: { + type: Object as PropType<TableAssessmentPayload>, + }, + }, + model: { + prop: 'payload', + event: 'save', + }, + data: () => ({ localCriteria: [] as EditorTableCriterium[] }), + computed: { + criteria() { + return this.payload.criteria; + }, + nonEmptyCriteria() { + return this.localCriteria.filter(({ text }) => text.trim().length); + }, + }, + methods: { + addLine() { + this.localCriteria.push({ text: '' }); + }, + removeLine(lineNumber: number) { + this.localCriteria = this.localCriteria.filter((item, index) => index !== lineNumber); + }, + resetLocalState() { + this.localCriteria = this.criteria.map(({ text }) => ({ text })); + }, + }, + mounted() { + this.resetLocalState(); + }, + watch: { + payload() { + this.resetLocalState(); + }, + localCriteria: { + handler() { + this.$emit('save', { criteria: this.nonEmptyCriteria.map((c) => ({ ...c })) }); + }, + deep: true, + }, + }, +}); +</script> + +<style scoped> +form button.trash { + min-width: 2em; + width: 2em; +} +form input { + flex-grow: 1; + height: 1.7em; + max-width: 48em; +} + +form .formpart { + display: flex; + align-items: center; + gap: 1em; +} + +.cw-peer-review-editor-table--preview label { + display: block; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeForm.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeForm.vue new file mode 100644 index 0000000..283031e --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeForm.vue @@ -0,0 +1,70 @@ +<template> + <article> + <form class="default studipform"> + <div class="formpart" v-for="(criterium, index) in criteria" :key="index"> + <LabelRequired + :id="`assessment-type-form-${index}`" + :label="criterium.text" + /> + <p>{{ criterium.description }}</p> + <textarea + :id="`assessment-type-form-${index}`" + required + aria-required="true" + :disabled="disabled" + v-model="answers[index]" + @change="changeAnswers" /> + </div> + </form> + </article> +</template> +<script> +import LabelRequired from '../../../../../forms/LabelRequired.vue'; + +export default { + components: { LabelRequired }, + props: { + disabled: { + type: Boolean, + default: false, + }, + process: { + type: Object, + required: true, + }, + review: { + type: Object, + required: true, + }, + }, + data() { + return { + answers: this.review.attributes.assessment?.answers ?? [], + }; + }, + computed: { + criteria() { + const payload = this.process.attributes.configuration.payload; + return payload.criteria ?? []; + }, + }, + methods: { + changeAnswers() { + const answers = this.criteria.map((_, index) => this.answers[index] ?? ''); + this.$emit('answer', { answers }); + }, + }, +}; +</script> + +<style scoped> +textarea { + min-height: 5em; + max-width: 48em; + width: 100%; +} + +.formpart + .formpart { + margin-block-start: 1rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeFreetext.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeFreetext.vue new file mode 100644 index 0000000..ae30207 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeFreetext.vue @@ -0,0 +1,63 @@ +<template> + <article> + <form class="default studipform"> + <div class="formpart"> + <LabelRequired + id="assessment-type-freetext" + :label="$gettext('Bewertung')" + /> + <textarea + id="assessment-type-freetext" + required + aria-required="true" + :disabled="disabled" + v-model="answer" + @change="changeAnswer" /> + </div> + </form> + </article> +</template> +<script> +import LabelRequired from '../../../../../forms/LabelRequired.vue'; + +export default { + components: { LabelRequired }, + props: { + disabled: { + type: Boolean, + default: false, + }, + process: { + type: Object, + required: true, + }, + review: { + type: Object, + required: true, + }, + }, + data() { + return { + answer: this.review.attributes.assessment?.answer ?? "", + }; + }, + methods: { + changeAnswer() { + const answer = this.answer ?? ''; + this.$emit('answer', { answer }); + }, + }, +}; +</script> + +<style scoped> +textarea { + min-height: 5em; + max-width: 48em; + width: 100%; +} + +.formpart + .formpart { + margin-block-start: 1rem; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeTable.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeTable.vue new file mode 100644 index 0000000..fa9e8f2 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/forms/AssessmentTypeTable.vue @@ -0,0 +1,106 @@ +<template> + <article> + <form class="default studipform"> + <div class="formpart" v-for="(criterium, index) in criteria" :key="index"> + <LabelRequired :id="`assessment-type-table-${index}`" :label="criterium.text" /> + <section> + <textarea + :id="`assessment-type-table-${index}`" + required + aria-required="true" + :disabled="disabled" + v-model="answers[index].text" + @change="changeAnswers" + /> + + <div> + <label v-for="(text, rating) in ratingLevels" :key="text" + ><input + :disabled="disabled" + v-model="answers[index].rating" + :name="`rating-${index}`" + type="radio" + :value="rating + 1" + @change="changeAnswers" + />{{ text }}</label + > + </div> + </section> + </div> + </form> + </article> +</template> +<script> +import { $gettext } from '../../../../../../../assets/javascripts/lib/gettext'; +import LabelRequired from '../../../../../forms/LabelRequired.vue'; + +const emptyAssessment = (criteria) => { + return { + answers: criteria.map((_) => ({ text: '', rating: 0 })), + }; +}; + +export default { + components: { LabelRequired }, + props: { + disabled: { + type: Boolean, + default: false, + }, + process: { + type: Object, + required: true, + }, + review: { + type: Object, + required: true, + }, + }, + data() { + return { + answers: [], + }; + }, + computed: { + criteria() { + const payload = this.process.attributes.configuration.payload; + return payload.criteria ?? []; + }, + ratingLevels() { + return [$gettext('gut'), $gettext('ok'), $gettext('schwach')]; + }, + }, + methods: { + changeAnswers() { + this.$emit('answer', { answers: this.answers }); + }, + }, + beforeMount() { + if (this.review.attributes.assessment && 'answers' in this.review.attributes.assessment) { + this.answers = this.review.attributes.assessment.answers; + } else { + this.answers = emptyAssessment(this.criteria).answers; + } + }, +}; +</script> + +<style scoped> +textarea { + min-height: 5em; + max-width: 48em; + width: 100%; +} + +.formpart + .formpart { + margin-block-start: 1rem; +} + +.formpart > section { + display: flex; +} + +.formpart > section label { + white-space: nowrap; +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Form.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Form.vue new file mode 100644 index 0000000..53b078a --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Form.vue @@ -0,0 +1,49 @@ +<template> + <article> + <section v-for="(criterium, index) in criteria" :key="index" class="criterium"> + <header>{{ criterium.text }}</header> + + <p class="criterium-description">{{ criterium.description }}</p> + + <p class="criterium-text">{{ answers[index] }}</p> + </section> + </article> +</template> + +<script> +export default { + props: { + process: { type: Object, required: true }, + review: { type: Object, required: true }, + }, + data() { + return { + answers: this.review.attributes.assessment?.answers ?? [], + }; + }, + computed: { + criteria() { + const payload = this.process.attributes.configuration.payload; + return payload.criteria ?? []; + }, + }, +}; +</script> + +<style scoped> +.criterium + .criterium { + margin-block-start: 1rem; +} + +.criterium header { + font-weight: bold; + margin-block: 1em; +} + +.criterium-description { + font-style: italic; +} + +.criterium-text { +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Freetext.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Freetext.vue new file mode 100644 index 0000000..f7bf101 --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Freetext.vue @@ -0,0 +1,33 @@ +<template> + <article> + <section class="criterium"> + <header>{{ $gettext('Bewertung') }}</header> + + <p class="criterium-text">{{ answer }}</p> + </section> + </article> +</template> + +<script> +export default { + props: { + process: { type: Object, required: true }, + review: { type: Object, required: true }, + }, + data() { + return { + answer: this.review.attributes.assessment?.answer ?? '', + }; + }, +}; +</script> + +<style scoped> +.criterium header { + font-weight: bold; + margin-block: 1em; +} + +.criterium-text { +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Table.vue b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Table.vue new file mode 100644 index 0000000..ae406df --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/assessment-types/results/Table.vue @@ -0,0 +1,66 @@ +<template> + <article> + <section v-for="(criterium, index) in criteria" :key="index" class="criterium"> + <header>{{ criterium.text }}</header> + + <div class="criterium-rating"> + <div>{{ $gettext('Bewertung') }}</div> + <p>{{ ratingLevels[answers[index].rating - 1] }}</p> + </div> + + <p class="criterium-text">{{ answers[index].text }}</p> + </section> + </article> +</template> + +<script> +const emptyAssessment = (criteria) => ({ + answers: criteria.map((_) => ({ text: '', rating: 0 })), +}); + +export default { + props: { + process: { type: Object, required: true }, + review: { type: Object, required: true }, + }, + data() { + return { + answers: [], + }; + }, + computed: { + criteria() { + const payload = this.process.attributes.configuration.payload; + return payload.criteria ?? []; + }, + ratingLevels() { + return [this.$gettext('gut'), this.$gettext('ok'), this.$gettext('schwach')]; + }, + }, + beforeMount() { + if (this.review.attributes.assessment && 'answers' in this.review.attributes.assessment) { + this.answers = this.review.attributes.assessment.answers; + } else { + this.answers = emptyAssessment(this.criteria).answers; + } + }, +}; +</script> + +<style scoped> +.criterium + .criterium { + margin-block-start: 1rem; +} + +.criterium header { + font-weight: bold; + margin-block: 1em; +} + +.criterium-rating > div { + font-weight: bold; +} + +.criterium-text { +} +</style> diff --git a/resources/vue/components/courseware/tasks/peer-review/definitions.ts b/resources/vue/components/courseware/tasks/peer-review/definitions.ts new file mode 100644 index 0000000..2e6729e --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/definitions.ts @@ -0,0 +1,57 @@ +import { $gettext } from '../../../../../assets/javascripts/lib/gettext'; + +export enum ProcessStatus { + Before = 'before', + After = 'after', + Active = 'active', +} + +export interface StatusDescriptor { + status: ProcessStatus; + shape: string; + role: string; + description: string; +} + +interface StringDict { + [key: string]: string; +} + +export interface JsonApiSchema { + id?: string; + type: string; + attributes: StringDict; + meta?: StringDict; + relationships?: StringDict; +} + +export function getProcessStatus(process: JsonApiSchema): StatusDescriptor { + const now = new Date(); + const startDate = new Date(process.attributes['review-start']); + const endDate = new Date(process.attributes['review-end']); + + if (now < startDate) { + return { + status: ProcessStatus.Before, + shape: 'span-empty', + role: 'status-yellow', + description: $gettext('Peer-Review-Process noch nicht aktiv'), + }; + } + + if (endDate < now) { + return { + status: ProcessStatus.After, + shape: 'span-full', + role: 'status-red', + description: $gettext('Peer-Review-Process beendet'), + }; + } + + return { + status: ProcessStatus.Active, + shape: 'span-empty', + role: 'status-green', + description: $gettext('Peer-Review-Prozess aktiv'), + }; +} diff --git a/resources/vue/components/courseware/tasks/peer-review/process-configuration.ts b/resources/vue/components/courseware/tasks/peer-review/process-configuration.ts new file mode 100644 index 0000000..04b952a --- /dev/null +++ b/resources/vue/components/courseware/tasks/peer-review/process-configuration.ts @@ -0,0 +1,129 @@ +import { $gettext } from '../../../../../assets/javascripts/lib/gettext'; + +export enum AssessmentType { + Form = 'form', + Freetext = 'freetext', + Table = 'table', +} + +export interface EditorFormCriterium { + text: string; + description: string; +} + +export interface EditorTableCriterium { + text: string; +} + +export type FormAssessmentPayload = { criteria: EditorFormCriterium[] }; +export type TableAssessmentPayload = { criteria: EditorTableCriterium[] }; +export type FreetextAssessmentPayload = {}; + +export type ProcessConfigurationPayload = FormAssessmentPayload | FreetextAssessmentPayload | TableAssessmentPayload; + +export interface ProcessConfiguration { + anonymous: boolean; + duration: number; + automaticPairing: boolean; + type: AssessmentType; + payload?: ProcessConfigurationPayload; +} + +export interface ConfigurationSet { + name: string; + configuration: ProcessConfiguration; +} + +export const ASSESSMENT_TYPES = { + [AssessmentType.Form]: { + short: $gettext('Formular'), + long: $gettext('Strukturiertes Bewertungssystem mit detailierten Fragen zur Begutachtung'), + defaultPayload: { criteria: defaultCriteriaForm() }, + }, + [AssessmentType.Freetext]: { + short: $gettext('Freitext'), + long: $gettext('Freitextliche Begutachtung'), + defaultPayload: { }, + }, + [AssessmentType.Table]: { + short: $gettext('Tabelle'), + long: $gettext('Einfaches Bewertungssystem mit 3 Bewertungsnoten und kurzer Erläuterung'), + defaultPayload: { criteria: defaultCriteriaTable() }, + }, +}; + +export const CONFIGURATION_SETS: Array<ConfigurationSet> = [ + { + name: $gettext('Kurz und bündig'), + configuration: { + anonymous: true, + duration: 7, + automaticPairing: true, + type: AssessmentType.Table, + payload: ASSESSMENT_TYPES[AssessmentType.Table].defaultPayload, + }, + }, + { + name: $gettext('Strukturiert begleitet'), + configuration: { + anonymous: true, + duration: 7, + automaticPairing: true, + type: AssessmentType.Form, + payload: ASSESSMENT_TYPES[AssessmentType.Form].defaultPayload, + }, + }, + { + name: $gettext('Selbstbestimmt'), + configuration: { + anonymous: true, + duration: 7, + automaticPairing: true, + type: AssessmentType.Freetext, + payload: ASSESSMENT_TYPES[AssessmentType.Freetext].defaultPayload, + }, + }, +]; + +export function defaultConfiguration(): ProcessConfiguration { + return CONFIGURATION_SETS[0].configuration; +} + +function defaultCriteriaForm() { + return [ + { + text: $gettext('Aufbau'), + description: $gettext( + 'Wo sind die grundlegenden Abschnitte (Einführung, Schlussfolgerung, Literatur, Zitate, usw.) und sind sie angemessen? Wenn nicht, was fehlt?\nHat der Schreiber verschiedene Überschriftenstile verwendet um die Abschnitte klar zu kennzeichnen? Kurze Erklärung.\nWie wurde der Inhalt geordnet? War er logisch, klar und leicht zu folgen? Kurze Erklärung.' + ), + }, + { + text: $gettext('Grammatik und Stil'), + description: $gettext( + 'Gibt es grammatische oder orthografische Probleme?\nIst der Schreibstil klar? Sind die Absätze und die enthaltenen Sätze zusammengehörig?' + ), + }, + { + text: $gettext('Inhalt'), + description: $gettext( + 'Hat der Autor hinreichend verdichtet und die Aufgabe diskutiert? Kurze Erklärung.\nHat der Autor umfassend Material aus Standardquellen benutzt? Wenn nicht, was fehlt?\nHat der Autor auch eigene Gedanken beigetragen, oder hat er mehrheitlich Zusammenfassungen von Veröffentlichungen/Daten zusammengetragen? Kurze Erklärung.' + ), + }, + { + text: $gettext('Zitate'), + description: $gettext( + 'Hat der Autor Zitatquellen passend und korrekt angebeben? Notiere unkorrekte Formatierungen.\nSind alle Zitate auch in dem Literaturhinweis zu finden? Notiere die Unstimmigkeiten.' + ), + }, + ]; +} + +function defaultCriteriaTable() { + return [ + { text: $gettext('These: Klarheit, Bedeutung') }, + { text: $gettext('Belege: Relevanz, Glaubwürdigkeit, Aussagekraft') }, + { text: $gettext('Aufbau: Anordnung des Inhalts, Nachvollziehbarkeit') }, + { text: $gettext('Handwerk: Orthografie, Grammatik, Zeichensetzung') }, + { text: $gettext('Gesamtwirkung') }, + ]; +} diff --git a/resources/vue/components/forms/LabelRequired.vue b/resources/vue/components/forms/LabelRequired.vue new file mode 100644 index 0000000..7a12377 --- /dev/null +++ b/resources/vue/components/forms/LabelRequired.vue @@ -0,0 +1,22 @@ +<template> + <label class="studiprequired" :for="id"> + <span class="textlabel">{{ label }}</span> + <span class="asterisk" :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true">*</span> + <slot></slot> + </label> +</template> + +<script> +export default { + props: { + id: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + }, +}; +</script> diff --git a/resources/vue/courseware-index-app.js b/resources/vue/courseware-index-app.js index 59c0ebc..e6f7720 100644 --- a/resources/vue/courseware-index-app.js +++ b/resources/vue/courseware-index-app.js @@ -102,6 +102,8 @@ const mountApp = async (STUDIP, createApp, element) => { 'courseware-containers', 'courseware-instances', 'courseware-public-links', + 'courseware-peer-reviews', + 'courseware-peer-review-processes', 'courseware-structural-elements', 'courseware-structural-element-comments', 'courseware-structural-element-feedback', diff --git a/resources/vue/courseware-tasks-app.js b/resources/vue/courseware-tasks-app.js index 9c01b71..2a5ecc6 100644 --- a/resources/vue/courseware-tasks-app.js +++ b/resources/vue/courseware-tasks-app.js @@ -1,3 +1,4 @@ +import PeerReviewProcessesIndex from './components/courseware/tasks/peer-review/PagesProcessesIndex.vue'; import TaskGroupsIndex from './components/courseware/tasks/PagesTaskGroupsIndex.vue'; import TaskGroupsShow from './components/courseware/tasks/PagesTaskGroupsShow.vue'; import { mapResourceModules } from '@elan-ev/reststate-vuex'; @@ -31,6 +32,11 @@ const mountApp = async (STUDIP, createApp, element) => { component: TaskGroupsShow, props: true, }, + { + path: '/peer-review-processes', + name: 'peer-review-processes-index', + component: PeerReviewProcessesIndex, + }, ]; const base = new URL( @@ -69,6 +75,8 @@ const mountApp = async (STUDIP, createApp, element) => { 'courseware-block-feedback', 'courseware-containers', 'courseware-instances', + 'courseware-peer-reviews', + 'courseware-peer-review-processes', 'courseware-structural-elements', 'courseware-task-feedback', 'courseware-task-groups', diff --git a/resources/vue/store/courseware/courseware-tasks.module.js b/resources/vue/store/courseware/courseware-tasks.module.js index 0622452..62a71f7 100644 --- a/resources/vue/store/courseware/courseware-tasks.module.js +++ b/resources/vue/store/courseware/courseware-tasks.module.js @@ -1,3 +1,5 @@ +import { ASSESSMENT_TYPES } from '../../components/courseware/tasks/peer-review/process-configuration'; + const getDefaultState = () => { return { showTaskGroupsAddSolversDialog: false, @@ -61,14 +63,15 @@ export const actions = { loadTasksOfCourse({ dispatch }, { cid }) { const options = { 'filter[cid]': cid, - include: 'solver, structural-element, task-feedback, task-group, task-group.lecturer', + include: + 'solver, structural-element, task-feedback, task-group, task-group.lecturer, task-group.peer-review-processes', }; return dispatch('courseware-tasks/loadAll', { options }, { root: true }); }, loadTaskGroup({ dispatch }, { id }) { const options = { - include: 'lecturer', + include: 'lecturer, peer-review-processes', }; return dispatch('courseware-task-groups/loadById', { id, options }, { root: true }); }, @@ -84,6 +87,83 @@ export const actions = { data: solvers, }); }, + + createPeerReviewProcess({ dispatch }, { taskGroup, options }) { + const { anonymous, duration, automaticPairing, type, payload } = options; + const startDate = new Date(taskGroup.attributes['end-date']); + startDate.setSeconds(startDate.getSeconds() + 1); + const endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + duration); + + const data = { + attributes: { + configuration: { anonymous, duration, automaticPairing, type, payload }, + 'review-start': startDate.toISOString(), + 'review-end': endDate.toISOString(), + }, + relationships: { + 'task-group': { + data: { + type: taskGroup.type, + id: taskGroup.id, + }, + }, + }, + }; + + return dispatch('courseware-peer-review-processes/create', data, { root: true }); + }, + + replacePairings({ dispatch, rootGetters }, { process, pairings }) { + const reviews = rootGetters['courseware-peer-reviews/related']({ + parent: process, + relationship: 'peer-reviews', + }); + const relation = ({ id, type }) => ({ data: { id, type } }); + const deleteReview = (review) => dispatch('courseware-peer-reviews/delete', review, { root: true }); + const createReview = (pairing) => + dispatch( + 'courseware-peer-reviews/create', + { + type: 'courseware-peer-reviews', + attributes: {}, + relationships: { + process: relation(process), + submitter: relation(pairing.submitter), + reviewer: relation(pairing.reviewer), + }, + }, + { root: true } + ); + + return Promise.all(reviews.map(deleteReview)).then(() => Promise.all(pairings.map(createReview))); + }, + + updatePeerReviewProcess({ dispatch, rootGetters }, { process, configuration }) { + const taskGroup = rootGetters['courseware-task-groups/related']({ + parent: process, + relationship: 'task-group', + }); + + const startDate = new Date(taskGroup.attributes['end-date']); + const endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + configuration.duration); + + if (_.isEmpty(configuration.payload)) { + configuration.payload = ASSESSMENT_TYPES[configuration.type].defaultPayload; + } + + process.attributes.configuration = configuration; + process.attributes['review-start'] = startDate.toISOString(); + process.attributes['review-end'] = endDate.toISOString(); + + return dispatch('courseware-peer-review-processes/update', process, { root: true }); + }, + + storeAssessment({ dispatch, rootGetters }, { review, assessment }) { + review.attributes.assessment = assessment; + return dispatch('courseware-peer-reviews/update', review, { root: true }); + }, }; export const mutations = { diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index 784b750..c7a52f5 100644 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -1393,7 +1393,7 @@ export const actions = { { id: taskId, options: { - include: 'solver,task-group,task-group.lecturer', + include: 'solver,task-group,task-group.lecturer,peer-reviews.process', }, }, { root: true } |
