diff --git a/frontend/src/components/BaseLayout.vue b/frontend/src/components/BaseLayout.vue index e6b4a8cf4f1f6be983f7241e0293e5db87c9b47c..27d2111b55e158eb7f8ca0e1e5ac8ed9f2ba6316 100644 --- a/frontend/src/components/BaseLayout.vue +++ b/frontend/src/components/BaseLayout.vue @@ -66,6 +66,7 @@ </v-navigation-drawer> <v-toolbar app + dense clipped-left fixed dark @@ -76,41 +77,28 @@ <span class="pl-2 grady-speak">{{ gradySpeak }}</span> </router-link> <v-spacer /> - <slot name="toolbar-center" /> - <div class="toolbar-content"> + <instance-actions /> + <v-divider vertical /> + <v-toolbar-items class="user-menu"> <v-menu - v-if="!isStudent" bottom offset-y + left > <v-btn id="user-options" slot="activator" - color="cyan" + flat style="text-transform: none" > - {{ userRole }} | {{ username }} <v-icon>arrow_drop_down</v-icon> + <v-icon left> + account_circle + </v-icon> + {{ username }} ({{ userRole }})<v-icon>arrow_drop_down</v-icon> </v-btn> - <user-options - v-if="!isStudent" - class="mt-1" - /> + <user-options /> </v-menu> - <span - v-else - style="color:#595959" - > - {{ username }} - </span> - </div> - <slot name="toolbar-right" /> - <v-btn - id="logout" - color="blue darken-1" - @click.native="logout" - > - Logout - </v-btn> + </v-toolbar-items> </v-toolbar> </div> </template> @@ -120,12 +108,12 @@ import { mapGetters, mapState } from 'vuex' import { UI } from '@/store/modules/ui' import { mapStateToComputedGetterSetter } from '@/util/helpers' import UserOptions from '@/components/UserOptions' +import InstanceActions from '@/components/InstanceActions' import { Authentication } from '@/store/modules/authentication' -import { actions } from '@/store/actions' export default { name: 'BaseLayout', - components: { UserOptions }, + components: { InstanceActions, UserOptions }, computed: { username () { return Authentication.state.user.username }, userRole () { return Authentication.state.user.role }, @@ -151,9 +139,6 @@ export default { }) }, methods: { - logout () { - actions.logout() - }, logFeedbackClick () { this.darkModeUnlocked = true } @@ -172,10 +157,6 @@ export default { color: grey; } - .toolbar-content { - margin-left: auto; - } - .grady-toolbar { font-weight: bold; } @@ -184,3 +165,9 @@ export default { color: gray; } </style> + +<style> + .grady-toolbar > div { + padding-right: 0; + } +</style> diff --git a/frontend/src/components/FreeLocksButton.vue b/frontend/src/components/FreeLocksButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..cdff30c03d8009d80e9490c3bb0bbac0aa6e2ef4 --- /dev/null +++ b/frontend/src/components/FreeLocksButton.vue @@ -0,0 +1,52 @@ +<template> + <v-tooltip bottom> + <v-btn + slot="activator" + flat + icon + :disabled="!activeAssignmentsExist" + :loading="loading" + @click="freeLocks" + > + <v-icon>vpn_key</v-icon> + </v-btn> + <span>Free all locked Submissions</span> + </v-tooltip> +</template> + +<script> +import { deleteAllActiveAssignments, fetchActiveAssignments } from '@/api' +import { TutorOverview } from '@/store/modules/tutor-overview' + +export default { + name: 'FreeLocksButton', + data () { + return { + activeAssignmentsExist: false, + loading: false, + shortPollInterval: null + } + }, + async created () { + this.activeAssignmentsExist = await this.checkForActiveAssignments() + this.shortPollInterval = setInterval(async () => { + this.activeAssignmentsExist = await this.checkForActiveAssignments() + } , 5e3) + }, + beforeDestroy () { + clearInterval(this.shortPollInterval) + }, + methods: { + async checkForActiveAssignments () { + return (await fetchActiveAssignments()).length > 0 + }, + async freeLocks () { + this.loading = true + await deleteAllActiveAssignments() + this.loading = false + // Just lie to the user for now. The actual value will be fetched by the timeout soon. + this.activeAssignmentsExist = false + } + } +} +</script> diff --git a/frontend/src/components/InstanceActions.vue b/frontend/src/components/InstanceActions.vue new file mode 100644 index 0000000000000000000000000000000000000000..aa472dba36fff9c0c00593037e44f8e3dd607e51 --- /dev/null +++ b/frontend/src/components/InstanceActions.vue @@ -0,0 +1,73 @@ +<template> + <div> + <export-dialog v-if="isReviewer" /> + <template v-for="(a, i) in actions"> + <v-tooltip + v-if="a.condition()" + :id="a.id" + :key="i" + bottom + > + <v-btn + slot="activator" + flat + icon + @click="a.action" + > + <v-icon>{{ a.icon }}</v-icon> + </v-btn> + {{ a.text }} + </v-tooltip> + </template> + <free-locks-button v-if="isReviewer" /> + <component + :is="displayComponent" + v-if="displayComponent" + @hide="hideComponent" + /> + </div> +</template> + +<script> +import ExportDialog from '@/components/export/ExportDialog' +import ImportDialog from '@/components/ImportDialog' +import ConfigDialog from '@/components/instance_config/ConfigDialog' +import FreeLocksButton from '@/components/FreeLocksButton' +import { Authentication } from '@/store/modules/authentication' +export default { + name: 'InstanceActions', + components: { ExportDialog, ImportDialog, FreeLocksButton }, + data () { + return { + displayComponent: null, + actions: [ + { + icon: 'publish', + text: 'Import exam data', + action: () => { this.displayComponent = ImportDialog }, + condition: () => Authentication.isReviewer, + id: 'import-data-list-tile' + }, + { + icon: 'settings', + text: 'Instance settings', + action: () => { this.displayComponent = ConfigDialog }, + condition: () => Authentication.isReviewer, + id: 'configure-instance-tile', + }, + ] + } + }, + computed: { + isReviewer: () => Authentication.isReviewer, + }, + methods: { + hideComponent () { + this.displayComponent = null + }, + logout () { + actions.logout() + } + } +} +</script> diff --git a/frontend/src/components/UserOptions.vue b/frontend/src/components/UserOptions.vue index 9be78c4ab17b44afaf51f22d5faa831e767f2411..8d3091a005f88e30f72702f91065e7dbd46676f4 100644 --- a/frontend/src/components/UserOptions.vue +++ b/frontend/src/components/UserOptions.vue @@ -1,16 +1,19 @@ <template> <div> <v-list> - <template v-for="(opt, i) in userOptions"> - <v-list-tile - v-if="opt.condition()" - :id="opt.id" - :key="i" - @click="opt.action" - > - {{ opt.display }} - </v-list-tile> - </template> + <v-list-tile + v-if="!isStudent" + @click="showPasswordChangeDialog" + > + Change password + </v-list-tile> + <v-divider class="my-2" /> + <v-list-tile @click="logout"> + <v-icon left> + exit_to_app + </v-icon> + Logout + </v-list-tile> </v-list> <component :is="displayComponent" @@ -22,47 +25,28 @@ <script> import PasswordChangeDialog from '@/components/PasswordChangeDialog' -import ImportDialog from '@/components/ImportDialog' -import ConfigDialog from '@/components/instance_config/ConfigDialog' import { Authentication } from '@/store/modules/authentication' -import { deleteAllActiveAssignments } from '@/api' +import { actions } from '@/store/actions' export default { name: 'UserOptions', - components: { PasswordChangeDialog, ImportDialog }, + components: { PasswordChangeDialog }, data () { return { displayComponent: null, - userOptions: [ - { - display: 'Change password', - action: () => { this.displayComponent = PasswordChangeDialog }, - condition: () => !Authentication.isStudent, - id: 'change-password-list-tile' - }, - { - display: 'Free all locked submissions', - action: deleteAllActiveAssignments, - condition: () => Authentication.isReviewer, - id: 'free-assignments-list-tile' - }, - { - display: 'Import data', - action: () => { this.displayComponent = ImportDialog }, - condition: () => Authentication.isReviewer, - id: 'import-data-list-tile' - }, - { - display: 'Configure current instance', - action: () => { this.displayComponent = ConfigDialog }, - condition: () => Authentication.isReviewer, - id: 'configure-instance-tile', - } - ] } }, + computed: { + isStudent: () => Authentication.isStudent, + }, methods: { hideComponent () { this.displayComponent = null + }, + logout () { + actions.logout() + }, + showPasswordChangeDialog () { + this.displayComponent = PasswordChangeDialog } } } diff --git a/frontend/src/components/export/ExportDialog.vue b/frontend/src/components/export/ExportDialog.vue index 3a4800e5752766d99ac84744e929b834bb0ddec0..458600cacd3571e6f834027d129189429adaa293 100644 --- a/frontend/src/components/export/ExportDialog.vue +++ b/frontend/src/components/export/ExportDialog.vue @@ -1,44 +1,50 @@ <template> - <div> - <v-menu offset-y> - <v-tooltip + <v-menu offset-y> + <v-tooltip + slot="activator" + bottom + > + <v-btn + id="export-btn" slot="activator" - left + :icon="!corrected" + :flat="!corrected" + :color="corrected ? 'success' : undefined" + style="text-transform: none;" > - <v-btn - id="export-btn" - slot="activator" - :color="exportColor" - > - export - <v-icon>file_download</v-icon> - </v-btn> - <span - v-if="corrected" - id="corrected-tooltip" - >All submissions have been corrected!</span> - <span - v-else - id="uncorrected-tooltip" - >UNCORRECTED submissions left! Export will be incomplete.</span> - </v-tooltip> - <v-list> - <v-list-tile - v-for="(item, i) in menuItems" - :id="'export-list' + i" - :key="i" - @click="item.action" - > - {{ item.display }} - </v-list-tile> - </v-list> - </v-menu> + <v-icon :left="corrected"> + file_download + </v-icon> + <span v-if="corrected"> + Export + </span> + </v-btn> + Export + <span + v-if="corrected" + id="corrected-tooltip" + >(All submissions have been corrected!)</span> + <span + v-else + id="uncorrected-tooltip" + >(UNCORRECTED submissions left! Export will be incomplete.)</span> + </v-tooltip> + <v-list> + <v-list-tile + v-for="(item, i) in menuItems" + :id="'export-list' + i" + :key="i" + @click="item.action" + > + {{ item.display }} + </v-list-tile> + </v-list> <component :is="displayComponent" v-if="displayComponent" @hide="displayComponent = null" /> - </div> + </v-menu> </template> <script lang="ts"> @@ -70,9 +76,6 @@ export default class ExportDialog extends Vue { get corrected () { return getters.corrected } - get exportColor () { - return this.corrected ? 'green darken-1' : 'red lighten-1' - } // apparently `this` is not the same when used within a // closure when defining data and within a method diff --git a/frontend/src/pages/base/TutorReviewerBaseLayout.vue b/frontend/src/pages/base/TutorReviewerBaseLayout.vue index 38cb84d7d000ef8210b0bb62f931a294fb50df29..23264611c5e60f6842cc0a88c4d1de841b126b45 100644 --- a/frontend/src/pages/base/TutorReviewerBaseLayout.vue +++ b/frontend/src/pages/base/TutorReviewerBaseLayout.vue @@ -27,9 +27,6 @@ <feedback-label-tab /> <slot name="below-subscriptions" /> </template> - <template slot="toolbar-right"> - <slot name="toolbar-right" /> - </template> </base-layout> </template> diff --git a/frontend/src/pages/reviewer/ReviewerLayout.vue b/frontend/src/pages/reviewer/ReviewerLayout.vue index e16033ebef7ef6ba9d2928be877a88734bdce9fa..ff1b2141255b4207fe829f5428b73286c5eeadf2 100644 --- a/frontend/src/pages/reviewer/ReviewerLayout.vue +++ b/frontend/src/pages/reviewer/ReviewerLayout.vue @@ -19,21 +19,15 @@ </v-list-tile-content> </v-list-tile> </v-list> - <template slot="toolbar-right"> - <export-dialog /> - </template> </tutor-reviewer-base-layout> </template> <script> import TutorReviewerBaseLayout from '@/pages/base/TutorReviewerBaseLayout' -import ExportDialog from '@/components/export/ExportDialog' export default { name: 'ReviewerLayout', - components: { - ExportDialog, - TutorReviewerBaseLayout }, + components: { TutorReviewerBaseLayout }, data () { return { subGeneralNavItems: [ diff --git a/frontend/src/store/getters.ts b/frontend/src/store/getters.ts index 9dada32cef8530cb0444461564b94d1e3dbd76dd..2ea338f6e1bd32226e6a12491d6abcc4d3695701 100644 --- a/frontend/src/store/getters.ts +++ b/frontend/src/store/getters.ts @@ -6,7 +6,8 @@ const mb = getStoreBuilder<RootState>() const stateGetter = mb.state() const correctedGetter = mb.read(function corrected (state) { - return state.statistics.submissionTypeProgress.every(progress => { + const progresses = state.statistics.submissionTypeProgress + return progresses.length > 0 && progresses.every(progress => { return progress.feedbackFinal === progress.submissionCount }) }) diff --git a/functional_tests/test_export_modal.py b/functional_tests/test_export_modal.py index beb5c7b8aa3fd96be53c270274d8138e1b4d89d7..c0636ef45da005dfa14f27d589541391fbc4baec 100644 --- a/functional_tests/test_export_modal.py +++ b/functional_tests/test_export_modal.py @@ -49,13 +49,13 @@ class ExportTestModal(GradyTestCase): login(self.browser, self.live_server_url, self.username, self.password) def test_export_red_uncorrected_submissions(self): - def export_btn_is_red(*args): + def export_btn_is_not_green(*args): exports_btn = self.browser.find_element_by_id('export-btn') - return 'red' in exports_btn.get_attribute('class') + return 'success' not in exports_btn.get_attribute('class') fact.SubmissionFactory() self._login() - WebDriverWait(self.browser, 10).until(export_btn_is_red) + WebDriverWait(self.browser, 10).until(export_btn_is_not_green) def test_export_warning_tooltip_uncorrected_submissions(self): fact.SubmissionFactory() @@ -65,11 +65,16 @@ class ExportTestModal(GradyTestCase): self.assertRaises(Exception, self.browser.find_element_by_id, 'corrected-tooltip') def test_export_green_all_corrected(self): + def export_btn_is_green(*args): + exports_btn = self.browser.find_element_by_id('export-btn') + return 'success' in exports_btn.get_attribute('class') + + fact.SubmissionTypeFactory() self._login() - exports_btn = self.browser.find_element_by_id('export-btn') - self.assertIn('green', exports_btn.get_attribute('class')) + WebDriverWait(self.browser, 10).until(export_btn_is_green) def test_export_all_good_tooltip_all_corrected(self): + fact.SubmissionTypeFactory() self._login() tooltip_corrected = self.browser.find_element_by_id('corrected-tooltip') self.assertNotEqual(None, tooltip_corrected)