From 4854b8f4c8a359767da1a78b8f9285e482c02397 Mon Sep 17 00:00:00 2001
From: Dominik Seeger <dominik.seeger@gmx.net>
Date: Tue, 4 Jun 2019 16:57:44 +0200
Subject: [PATCH] improved labelling system style and feel

---
 core/migrations/0016_auto_20190521_1803.py    |  18 ++
 core/models/feedback.py                       |   2 +-
 .../feedback_labels/FeedbackLabel.vue         |   1 +
 .../feedback_labels/FeedbackLabelForm.vue     |  57 +++---
 .../feedback_labels/FeedbackLabelUpdater.vue  |   7 +-
 .../feedback_labels/FeedbackLabelsList.vue    |   2 +-
 .../feedback_labels/LabelSelector.vue         | 163 ++++++++++++------
 .../components/mixins/commentLabelSelector.ts |  98 +++++++++++
 .../submission_notes/base/CommentForm.vue     |  69 ++++----
 .../submission_notes/base/FeedbackComment.vue |  94 ++++++++--
 frontend/src/models.ts                        |   1 +
 .../src/store/modules/submission-notes.ts     |  14 +-
 12 files changed, 400 insertions(+), 126 deletions(-)
 create mode 100644 core/migrations/0016_auto_20190521_1803.py
 create mode 100644 frontend/src/components/mixins/commentLabelSelector.ts

diff --git a/core/migrations/0016_auto_20190521_1803.py b/core/migrations/0016_auto_20190521_1803.py
new file mode 100644
index 00000000..93477a02
--- /dev/null
+++ b/core/migrations/0016_auto_20190521_1803.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2 on 2019-05-21 18:03
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0015_feedbacklabel_colour'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='feedbackcomment',
+            name='text',
+            field=models.TextField(blank=True),
+        ),
+    ]
diff --git a/core/models/feedback.py b/core/models/feedback.py
index fb933be3..15eea22c 100644
--- a/core/models/feedback.py
+++ b/core/models/feedback.py
@@ -77,7 +77,7 @@ class FeedbackComment(models.Model):
     comment_id = models.UUIDField(primary_key=True,
                                   default=uuid.uuid4,
                                   editable=False)
-    text = models.TextField()
+    text = models.TextField(blank=True)
     created = models.DateTimeField(auto_now_add=True)
     modified = models.DateTimeField(auto_now=True)
 
diff --git a/frontend/src/components/feedback_labels/FeedbackLabel.vue b/frontend/src/components/feedback_labels/FeedbackLabel.vue
index 1f21ee2c..bd97e85f 100644
--- a/frontend/src/components/feedback_labels/FeedbackLabel.vue
+++ b/frontend/src/components/feedback_labels/FeedbackLabel.vue
@@ -22,6 +22,7 @@ export default class FeedbackLabel extends Vue {
   @Prop({ type: String, required: true }) readonly description!: string
   @Prop({ type: String, required: true }) readonly colour!: string
   @Prop({ type: Boolean, default: false }) readonly removable!: boolean
+  
   onClose() {
     this.$emit("remove-clicked", this.pk)
   }
diff --git a/frontend/src/components/feedback_labels/FeedbackLabelForm.vue b/frontend/src/components/feedback_labels/FeedbackLabelForm.vue
index 3cd91c47..23796428 100644
--- a/frontend/src/components/feedback_labels/FeedbackLabelForm.vue
+++ b/frontend/src/components/feedback_labels/FeedbackLabelForm.vue
@@ -3,19 +3,19 @@
     <v-flex ml-3 xs9>
       <v-text-field
         label="Name"
-        v-model="name"
+        v-model="mutableName"
       />
     </v-flex>
     <v-flex ml-3 xs9>
       <v-textarea
         label="Description"
-        v-model="description"
+        v-model="mutableDescription"
         placeholder="The description can be seen when hovering above the label"
         auto-grow
       />
     </v-flex>
     <v-flex ml-2 xs12>
-      <compact-picker style="width:85%;box-shadow:none;" v-model="colour"/>
+      <compact-picker style="width:85%;box-shadow:none;" v-model="mutableColour"/>
     </v-flex>
     <v-flex ml-1 mb-3 xs4>
         <v-btn id="create-label-btn" v-if="!is_update" :loading="loading" color="teal" @click="createLabel">Create</v-btn>
@@ -27,7 +27,7 @@
 <script lang="ts">
 import Vue from "vue"
 import Component from "vue-class-component"
-import { Prop } from "vue-property-decorator"
+import { Prop, Watch } from "vue-property-decorator"
 import * as api from "@/api";
 import { Compact } from "vue-color"
 import { FeedbackLabels } from "@/store/modules/feedback-labels";
@@ -38,29 +38,45 @@ import { FeedbackLabels } from "@/store/modules/feedback-labels";
   }
 })
 export default class FeedbackLabelForm extends Vue {
-  @Prop({ type: String, default: "" }) name!: string
-  @Prop({ type: String, default: "" }) description!: string
-  @Prop({ type: String, default: "#4d4d4d" }) colour!: string
+  @Prop({ type: String, default: "" }) readonly name!: string
+  @Prop({ type: String, default: "" }) readonly description!: string
+  @Prop({ type: String, default: "#4d4d4d" }) readonly colour!: string
   @Prop({ type: Number, required: false }) readonly pk!: number
   @Prop({ type: Boolean, default: false }) readonly is_update!: boolean
 
+  mutableColour = this.colour
+  mutableName = this.name
+  mutableDescription = this.description
+
   loading = false
 
+  @Watch('pk')
+  onPkChange() { this.resetFields() }
+
+  resetFields () {
+    this.mutableName = this.name
+    this.mutableDescription = this.description
+    this.mutableColour = this.colour
+  }
+
+  get label () {
+    return {
+      name: this.mutableName,
+      description: this.mutableDescription,
+      // @ts-ignore
+      colour: this.mutableColour.hex || this.mutableColour  // hex may be undefined when colour comes from the updater
+    }
+  }
+
   get feedbackLabels () {
     return FeedbackLabels.availableLabels
   }
 
   async createLabel () {
     this.loading = true
-    const label = {
-      name: this.name,
-      description: this.description,
-      // @ts-ignore
-      colour: this.colour.hex,
-    }
 
     const duplicate = this.feedbackLabels.find((val) => {
-      return val.name === label.name
+      return val.name === this.label.name
     })
 
     if (duplicate) {
@@ -78,7 +94,7 @@ export default class FeedbackLabelForm extends Vue {
 
     let res
     try {
-      res = await api.createLabel(label)
+      res = await api.createLabel(this.label)
     } catch (ex) {
       // user will be notified by the interceptor
       this.resetFields()
@@ -94,11 +110,8 @@ export default class FeedbackLabelForm extends Vue {
   async updateLabel () {
     this.loading = true
     const label = {
+      ...this.label,
       pk: this.pk,
-      name: this.name,
-      description: this.description,
-      // @ts-ignore
-      colour: this.colour.hex,
     }
 
     let res
@@ -114,12 +127,6 @@ export default class FeedbackLabelForm extends Vue {
     this.$emit("label-updated", label.pk)
     this.loading = false
   }
-
-  resetFields () {
-    this.name = ""
-    this.description = ""
-    this.colour = "#4d4d4d"
-  }
 }
 </script>
 
diff --git a/frontend/src/components/feedback_labels/FeedbackLabelUpdater.vue b/frontend/src/components/feedback_labels/FeedbackLabelUpdater.vue
index 4d6a8dde..7b02cc51 100644
--- a/frontend/src/components/feedback_labels/FeedbackLabelUpdater.vue
+++ b/frontend/src/components/feedback_labels/FeedbackLabelUpdater.vue
@@ -13,10 +13,7 @@
     <v-flex xs12 v-if="label.pk !== -1">
       <feedback-label-form
         is_update
-        :pk="label.pk"
-        :name="label.name"
-        :description="label.description"
-        :colour="label.colour"
+        v-bind="currentLabel"
         @label-updated="setLabel"
       />
     </v-flex>
@@ -44,6 +41,8 @@ export default class FeedbackLabelUpdater extends Vue {
   }
   loading = false
 
+  get currentLabel () {  return this.label }
+
   get feedbackLabels() {
     return FeedbackLabels.availableLabels
   }
diff --git a/frontend/src/components/feedback_labels/FeedbackLabelsList.vue b/frontend/src/components/feedback_labels/FeedbackLabelsList.vue
index d4ea7817..db30f7e3 100644
--- a/frontend/src/components/feedback_labels/FeedbackLabelsList.vue
+++ b/frontend/src/components/feedback_labels/FeedbackLabelsList.vue
@@ -2,7 +2,7 @@
   <v-card>
     <v-toolbar color="teal" :dense="sidebar">
       <v-toolbar-side-icon>
-        <v-icon>chat_bubble</v-icon>
+        <v-icon>label</v-icon>
       </v-toolbar-side-icon>
       <v-toolbar-title v-if="showDetail" style="min-width: fit-content;">
         Labels
diff --git a/frontend/src/components/feedback_labels/LabelSelector.vue b/frontend/src/components/feedback_labels/LabelSelector.vue
index d0725b33..1c4f6bb2 100644
--- a/frontend/src/components/feedback_labels/LabelSelector.vue
+++ b/frontend/src/components/feedback_labels/LabelSelector.vue
@@ -2,8 +2,8 @@
   <v-card>
     <v-card-title>Assign labels</v-card-title>
     <v-divider/>
-    <v-layout>
-      <v-flex ml-2 lg5>
+    <v-layout wrap>
+      <v-flex ml-2 sm10>
         <v-autocomplete
           :items="feedbackLabels"
           item-text="name"
@@ -13,15 +13,44 @@
           @input="addLabel"
         />
       </v-flex>
-      <v-flex lg8>
-        <feedback-label
-          removable
-          v-for="label in labelsToShow"
-          v-bind="label"
-          :key="label.pk"
-          @remove-clicked="removeLabel"
-        />
-      </v-flex>
+      <v-layout ml-2 mb-3>
+        <v-flex sm4>
+          <v-flex sm12>
+            UNCHANGED
+          </v-flex>
+          <feedback-label
+            removable
+            v-for="label in unchangedMapped"
+            v-bind="label"
+            :key="label.pk"
+            @remove-clicked="removeLabel"
+          />
+        </v-flex>
+        <v-flex sm4>
+          <v-flex sm12>
+            WILL BE REMOVED
+          </v-flex>
+          <feedback-label
+            removable
+            v-for="label in removedMapped"
+            v-bind="label"
+            :key="label.pk"
+            @remove-clicked="addLabel"
+          />
+        </v-flex>
+        <v-flex sm4>
+          <v-flex sm12>
+            WILL BE ADDED
+          </v-flex>
+          <feedback-label
+            removable
+            v-for="label in addedMapped"
+            v-bind="label"
+            :key="label.pk"
+            @remove-clicked="removeLabel"
+          />
+        </v-flex>
+      </v-layout>
     </v-layout>
   </v-card>
 </template>
@@ -33,7 +62,7 @@ import { Prop } from "vue-property-decorator";
 import { FeedbackLabels } from '@/store/modules/feedback-labels'
 import { SubmissionNotes } from '@/store/modules/submission-notes'
 import FeedbackLabel from "@/components/feedback_labels/FeedbackLabel.vue"
-import { FeedbackComment } from '../../models';
+import { FeedbackComment, SubmissionType } from '../../models';
 
 @Component({
   components: {
@@ -43,49 +72,83 @@ import { FeedbackComment } from '../../models';
 export default class LabelSelector extends Vue {
   @Prop({ type: String }) readonly lineNo!: string
   @Prop({ type: Boolean, required: true }) readonly assignedToFeedback!: boolean
-  @Prop({ type: Array }) readonly labelsDraft!: number[]
+  @Prop({ type: Array }) readonly labelsUnchanged!: number[]
+  @Prop({ type: Array }) readonly labelsAdded!: number[]
+  @Prop({ type: Array }) readonly labelsRemoved!: number[]
 
-  get feedbackLabels() {
+  get feedbackLabels () {
     return FeedbackLabels.availableLabels
   }
 
-  get labelsToShow() {
+  get unchangedMapped() {
     if (this.assignedToFeedback) {
-      return this.assignedFeedbackLabels()
+      return this.mapPksToLabelObj(this.unchangedFeedbackLabels())
+    } else {
+      return this.mapPksToLabelObj(this.labelsUnchanged)
     }
-    return this.mapPksToLabelObj(this.labelsDraft)
   }
 
-  get labelsGetter () {
-    if (SubmissionNotes.state.changedLabels) {
-      return SubmissionNotes.state.updatedFeedback.labels
+  get removedMapped() {
+    if (this.assignedToFeedback) {
+      return this.mapPksToLabelObj(this.removedFeedbackLabels())
+    } else {
+      return this.mapPksToLabelObj(this.labelsRemoved)
     }
+  }
 
-    // merge labels from originalFeedback and updatedFeedback
-    let merged: number[] = []
-    const concated = SubmissionNotes.state.origFeedback.labels.concat(
-      SubmissionNotes.state.updatedFeedback.labels
-    )
+  get addedMapped() {
+    if (this.assignedToFeedback) {
+      return this.mapPksToLabelObj(this.addedFeedbackLabels())
+    } else {
+      return this.mapPksToLabelObj(this.labelsAdded)
+    }
+  }
 
-    concated.forEach((val) => {
-      if (!(SubmissionNotes.state.origFeedback.labels.includes(val) &&
-        SubmissionNotes.state.updatedFeedback.labels.includes(val))) {
-          merged.push(val)
-      }
+  /**
+   * Returns an array of label pk's that have not changed from origFeedback to updatedFeedback
+   */
+  unchangedFeedbackLabels() {
+    const labelsOrig = SubmissionNotes.state.origFeedback.labels
+    if (labelsOrig === undefined) return new Array()
+
+    const labelsDeleted = this.removedFeedbackLabels()
+    const labelsAdded = this.addedFeedbackLabels()
+
+    return labelsOrig.filter((label) => {
+      return !labelsAdded.includes(label) && !labelsDeleted.includes(label)
     })
+  }
 
-    return merged
+  /**
+   * Returns an array of label pk's that have been removed in updatedFeedback
+   * but exist in origFeedback
+   */
+  removedFeedbackLabels() {
+    if (!SubmissionNotes.state.changedLabels) return new Array()
+    
+    const labelsOrig = SubmissionNotes.state.origFeedback.labels
+    const labelsUpdated = SubmissionNotes.state.updatedFeedback.labels
+    
+    if (labelsOrig === undefined) return new Array()
+    
+    return labelsOrig.filter((label) => {
+      return !labelsUpdated.includes(label)
+    })
   }
 
   /**
-   * Returns an array of labels assigned to the currently loaded feedback
+   * Returns an array of label pk's that have been added in updatedFeedback
+   * but do not exist in origFeedback
    */
-  assignedFeedbackLabels() {
-    const labels = this.labelsGetter
+  addedFeedbackLabels() {
+    const labelsOrig = SubmissionNotes.state.origFeedback.labels
+    const labelsUpdated = SubmissionNotes.state.updatedFeedback.labels
 
-    if (labels.length === 0) return {}
-    const mapped = this.mapPksToLabelObj(labels)
-    return mapped ? mapped : {}
+    if (labelsOrig === undefined) return new Array()
+
+    return labelsUpdated.filter((label) => {
+      return !labelsOrig.includes(label)
+    })
   }
 
   /**
@@ -115,12 +178,14 @@ export default class LabelSelector extends Vue {
    */
   removeLabel(pk: number) {
     if (this.assignedToFeedback) {
-      const labels = this.labelsGetter.filter((val) => {
-        return val !== pk
-      })
-      SubmissionNotes.SET_FEEDBACK_LABELS(labels)
+      if (!SubmissionNotes.state.changedLabels) {
+        SubmissionNotes.SET_FEEDBACK_LABELS(SubmissionNotes.state.origFeedback.labels)
+      }
+  
+      SubmissionNotes.REMOVE_FEEDBACK_LABEL(pk)
+    } else {
+      this.$emit("label-removed", pk)
     }
-    this.$emit("label-removed", pk)
   }
 
   /**
@@ -129,17 +194,11 @@ export default class LabelSelector extends Vue {
    * Calling this with an already added label will instead remove the label
    */
   addLabel(pk: number) {
-    // there seems to be an issue with the autocomplete
-    // which fires when a user hits backspace
-    if (pk == undefined) return
     if (this.assignedToFeedback) {
-      let labels = this.labelsGetter
-
-      if (!labels.includes(pk)) {
-        labels.push(pk)
-        SubmissionNotes.SET_FEEDBACK_LABELS(labels)
-      } else {
-        this.removeLabel(pk)
+      if (!this.unchangedFeedbackLabels().includes(pk) && 
+          !this.addedFeedbackLabels().includes(pk)) 
+      {
+          SubmissionNotes.ADD_FEEDBACK_LABEL(pk)
       }
     } else {
       this.$emit("label-added", pk)
diff --git a/frontend/src/components/mixins/commentLabelSelector.ts b/frontend/src/components/mixins/commentLabelSelector.ts
new file mode 100644
index 00000000..4076ac3d
--- /dev/null
+++ b/frontend/src/components/mixins/commentLabelSelector.ts
@@ -0,0 +1,98 @@
+import Vue from 'vue'
+import Component from 'vue-class-component'
+import { Prop } from "vue-property-decorator"
+import { SubmissionNotes } from "@/store/modules/submission-notes"
+import { FeedbackComment, FeedbackLabel } from "@/models"
+import { FeedbackLabels } from "@/store/modules/feedback-labels"
+
+@Component
+export default class commentLabelSelector extends Vue {
+  @Prop({ type: String, required: true }) readonly lineNo!: string
+
+  /**
+   * Returns array of label pk's where feedbackType is
+   * either "origFeedback" or "updatedFeedback"
+   */
+  copyStateLabels(feedbackType: string): number[] {
+    if (feedbackType !== "origFeedback" && feedbackType !== "updatedFeedback") return new Array()
+    
+    const currentLine = this.getFeedbackLine(feedbackType)
+    return currentLine ? currentLine.labels : new Array()
+  }
+
+  getFeedbackLine (feedbackType: string): FeedbackComment | undefined {
+    if (feedbackType !== "origFeedback" && feedbackType !== "updatedFeedback") return undefined
+
+    const stateLines = SubmissionNotes.state[feedbackType].feedbackLines
+    if (stateLines && Object.keys(stateLines).length > 0) {
+      let lines = stateLines[Number(this.lineNo)]
+      if (lines === undefined) return undefined
+
+      // always copy latest comment
+      if (lines.length > 0) {
+        return lines[lines.length-1]
+      } else {
+        // @ts-ignore
+        return lines
+      }
+    }
+  }
+
+  getUnchangedLabels() {
+    const labelsOrig = this.copyStateLabels("origFeedback")
+    if (labelsOrig === undefined) return new Array()
+
+    const removedLabels = this.getRemovedLabels()
+    const addedLabels = this.getAddedLabels()
+
+    return labelsOrig.filter((val) => {
+      return !removedLabels.includes(val) && !addedLabels.includes(val)
+    })
+  }
+
+  getRemovedLabels() {
+    const currentLine = this.getFeedbackLine("updatedFeedback")
+    if (currentLine === undefined) return new Array()
+
+    const labelsOrig = this.copyStateLabels("origFeedback")
+    const labelsUpdated = this.copyStateLabels("updatedFeedback")
+
+    if (labelsOrig == undefined) return new Array()
+
+    return labelsOrig.filter((val) => {
+      return !labelsUpdated.includes(val)
+    })
+  }
+
+  getAddedLabels() {
+    const labelsOrig = this.copyStateLabels("origFeedback")
+    const labelsUpdated = this.copyStateLabels("updatedFeedback")
+
+    if (labelsOrig === undefined) return new Array()
+
+    return labelsUpdated.filter((val) => {
+      return !labelsOrig.includes(val)
+    })
+  }
+
+  /**
+   * Maps label pk's to the objects stored in vuex store
+   */
+  mapPksToLabelObj(pkArr: number[]): FeedbackLabel[] {
+    const mappedLabels = pkArr.map((val) => {
+      const label = FeedbackLabels.availableLabels.find((label) => {
+        return label.pk === val
+      })
+
+      if (!label) return
+      return { 
+        pk: val,
+        name: label.name,
+        description: label.description,
+        colour: label.colour
+      }
+    })
+
+    return mappedLabels ? mappedLabels : new Array()
+  }
+}
\ No newline at end of file
diff --git a/frontend/src/components/submission_notes/base/CommentForm.vue b/frontend/src/components/submission_notes/base/CommentForm.vue
index 9b27fc39..f768e78d 100644
--- a/frontend/src/components/submission_notes/base/CommentForm.vue
+++ b/frontend/src/components/submission_notes/base/CommentForm.vue
@@ -19,7 +19,9 @@
       <label-selector
         :assignedToFeedback="false"
         :lineNo="this.lineNo"
-        :labelsDraft="this.labelsDraft"
+        :labelsUnchanged="labelsUnchanged"
+        :labelsAdded="labelsAdded"
+        :labelsRemoved="labelsRemoved"
         @label-added="labelAdded"
         @label-removed="labelRemoved"
       />
@@ -33,23 +35,26 @@
 
 <script lang="ts">
 import Vue from 'vue'
-import Component from 'vue-class-component'
+import Component, { mixins } from 'vue-class-component'
 import { Prop, Watch } from 'vue-property-decorator'
 import { SubmissionNotes } from '@/store/modules/submission-notes'
 import LabelSelector from "@/components/feedback_labels/LabelSelector.vue"
-import { FeedbackComment } from '@/models';
+import { FeedbackComment, SubmissionType } from '@/models';
+import commentLabelSelector from "@/components/mixins/commentLabelSelector"
 
 @Component({
   components: {
     LabelSelector
   }
 })
-export default class CommentForm extends Vue {
+export default class CommentForm extends mixins(commentLabelSelector) {
   @Prop({ type: String, default: '' }) readonly feedback!: string
   @Prop({ type: String, required: true }) readonly lineNo!: string
 
   currentFeedback = this.feedback
-  labelsDraft: number[] = this.copyExistingLabels()
+  labelsUnchanged: number[] = this.getUnchangedLabels()
+  labelsAdded: number[] = this.getAddedLabels()
+  labelsRemoved: number[] = this.getRemovedLabels()
 
   selectInput (event: Event) {
     if (event !== null) {
@@ -58,46 +63,50 @@ export default class CommentForm extends Vue {
     }
   }
 
-  copyExistingLabels(): number[] {
-    const linesOrig = SubmissionNotes.state.origFeedback.feedbackLines
-    const linesUpdated = SubmissionNotes.state.updatedFeedback.feedbackLines
-
-    // priority for updatedFeedback and always select last created comment
-    if (linesUpdated && Object.keys(linesUpdated).length > 0) {
-      let line = <Partial<FeedbackComment>> linesUpdated[Number(this.lineNo)]
-      if (line.labels) return line.labels
-    } else if (linesOrig && Object.keys(linesOrig).length > 0) {
-      let lines = linesOrig[Number(this.lineNo)]
-      return lines[lines.length - 1].labels
-    }
-
-    return new Array()
-  }
-
   collapseTextField () {
     this.$emit('collapseFeedbackForm')
   }
 
+  /**
+   * Adds label pk to the array of added labels
+   * or adds already removed labels to unchanged array
+   */
   labelAdded (pk: number) {
-    if (this.labelsDraft.includes(pk)) {
-      this.labelRemoved(pk)
-    } else {
-      this.labelsDraft.push(pk)
+    if (this.labelsRemoved.includes(pk)) {
+      this.labelsUnchanged.push(pk)
+      this.labelsRemoved = this.labelsRemoved.filter((val) => {
+        return val !== pk
+      })
+    } else if (!this.labelsAdded.includes(pk) && 
+      !this.labelsUnchanged.includes(pk)) 
+    {
+      this.labelsAdded.push(pk)
     }
   }
 
+  /**
+   * Adds label pk to the array of removed labels
+   * or removes already added labels from the list of added labels
+   */
   labelRemoved (pk: number) {
-    this.labelsDraft = this.labelsDraft.filter((val) => {
-      return val !== pk
-    })
+    if (this.labelsAdded.includes(pk)) {
+      this.labelsAdded = this.labelsAdded.filter((val) => {
+        return val !== pk
+      })
+    } else if (!this.labelsRemoved.includes(pk)) {
+      this.labelsRemoved.push(pk)
+      this.labelsUnchanged = this.labelsUnchanged.filter((val) => {
+        return val !== pk
+      })
+    }
   }
   
-  submitFeedback (labelPk?: string) {
+  submitFeedback () {
     SubmissionNotes.UPDATE_FEEDBACK_LINE({
       lineNo: Number(this.lineNo),
       comment: {
         text: this.currentFeedback,
-        labels: this.labelsDraft,
+        labels: this.labelsUnchanged.concat(this.labelsAdded),
       }
     })
     this.collapseTextField()
diff --git a/frontend/src/components/submission_notes/base/FeedbackComment.vue b/frontend/src/components/submission_notes/base/FeedbackComment.vue
index a8cb7452..8a497083 100644
--- a/frontend/src/components/submission_notes/base/FeedbackComment.vue
+++ b/frontend/src/components/submission_notes/base/FeedbackComment.vue
@@ -1,6 +1,6 @@
 <template>
   <div class="dialog-box">
-    <div class="body elevation-1" :style="{borderColor: borderColor, backgroundColor}">
+    <div v-if="commentDisplayable" class="body elevation-1" :style="{borderColor: borderColor, backgroundColor}">
       <span class="tip tip-up" :style="{borderBottomColor: borderColor}"></span>
       <span v-if="ofTutor" class="of-tutor">Of tutor: {{ofTutor}}</span>
       <span class="comment-created">{{parsedCreated}}</span>
@@ -32,12 +32,41 @@
         <v-icon v-else size="20px">restore</v-icon>
       </v-btn>
     </div>
-    <v-layout>
-      <v-flex>
+    <v-layout v-if="showLabels" ml-2>
+      <v-flex sm4>
+        <v-flex sm12>
+          UNCHANGED
+        </v-flex>
         <feedback-label
-          v-for="label in labelsToShow"
+          removable
+          v-for="label in unchangedLabels"
           v-bind="label"
           :key="label.pk"
+          @remove-clicked="deleteAction"
+        />
+      </v-flex>
+      <v-flex sm4>
+        <v-flex sm12>
+          WILL BE REMOVED
+        </v-flex>
+        <feedback-label
+          removable
+          v-for="label in removedLabels"
+          v-bind="label"
+          :key="label.pk"
+          @remove-clicked="deleteAction"
+        />
+      </v-flex>
+      <v-flex sm4>
+        <v-flex sm12>
+          WILL BE ADDED
+        </v-flex>
+        <feedback-label
+          removable
+          v-for="label in addedLabels"
+          v-bind="label"
+          :key="label.pk"
+          @remove-clicked="deleteAction"
         />
       </v-flex>
     </v-layout>
@@ -50,15 +79,16 @@ import { UI } from '@/store/modules/ui'
 import { SubmissionNotes } from '@/store/modules/submission-notes'
 import FeedbackLabel from "@/components/feedback_labels/FeedbackLabel.vue"
 import { FeedbackLabels as Labels } from '@/store/modules/feedback-labels'
-
-// TODO: allow for displaying of empty comments when they have labels assigned
-// TODO: also make labels directly removable 
+import commentLabelSelector from "@/components/mixins/commentLabelSelector"
 
 export default {
   name: 'feedback-comment',
   components: {
     FeedbackLabel,
   },
+  mixins: [
+    commentLabelSelector,
+  ],
   props: {
     pk: {
       type: String,
@@ -91,13 +121,16 @@ export default {
     showVisibilityIcon: {
       type: Boolean,
       default: true
-    },
-    labels: {
-      type: Array,
-      required: true,
-    },
+    }
   },
   computed: {
+    commentDisplayable () { return this.text !== "" },
+    showLabels () { 
+      return this.visibleToStudent && 
+      (this.getUnchangedLabels().length > 0 ||
+       this.getAddedLabels().length > 0 ||
+       this.getRemovedLabels().length > 0)
+    },
     markedForDeletion () { return SubmissionNotes.state.commentsMarkedForDeletion },
     parsedCreated () {
       if (this.created) {
@@ -131,8 +164,45 @@ export default {
       })
       return mappedLabels ? mappedLabels : new Array()
     },
+    unchangedLabels() {
+      return this.mapPksToLabelObj(this.getUnchangedLabels())
+    },
+    addedLabels() {
+      return this.mapPksToLabelObj(this.getAddedLabels())
+    },
+    removedLabels() {
+      return this.mapPksToLabelObj(this.getRemovedLabels())
+    }
   },
   methods: {
+    deleteAction (pk) {
+      let labels
+      const concated = this.getUnchangedLabels().concat(this.getAddedLabels())
+      if (this.getUnchangedLabels().includes(pk)) {
+        labels = concated.filter((val) => {
+          return val !== pk
+        })
+      } else if (this.getAddedLabels().includes(pk)) {
+        labels = concated.filter((val) => {
+          return val !== pk
+        })
+      } else if (this.getRemovedLabels().includes(pk)) {
+        concated.push(pk)
+        labels = concated
+      }
+
+      if (labels.length > 0 || SubmissionNotes.state.hasOrigFeedback || this.commentDisplayable) {
+        SubmissionNotes.UPDATE_FEEDBACK_LINE({
+          lineNo: Number(this.lineNo),
+          comment: {
+            text: this.text || "",
+            labels: labels,
+          }
+        })
+      } else {
+        SubmissionNotes.DELETE_FEEDBACK_LINE(Number(this.lineNo))
+      }
+    },
     toggleDeleteComment () {
       if (this.pk) {
         if (!this.markedForDeletion.hasOwnProperty(this.pk)) {
diff --git a/frontend/src/models.ts b/frontend/src/models.ts
index 302089d3..b24847d8 100644
--- a/frontend/src/models.ts
+++ b/frontend/src/models.ts
@@ -185,6 +185,7 @@ export interface FeedbackComment {
      */
     visibleToStudent?: boolean
     labels: number[]
+    updated?: boolean
 }
 
 /**
diff --git a/frontend/src/store/modules/submission-notes.ts b/frontend/src/store/modules/submission-notes.ts
index 51c8f6f1..9055ea81 100644
--- a/frontend/src/store/modules/submission-notes.ts
+++ b/frontend/src/store/modules/submission-notes.ts
@@ -96,7 +96,6 @@ function SET_ORIG_FEEDBACK(state: SubmissionNotesState, feedback: Feedback) {
   if (feedback) {
     state.origFeedback = feedback
     state.hasOrigFeedback = true
-    //state.updatedFeedback.labels = feedback.labels
   }
 }
 function SET_SHOW_FEEDBACK(state: SubmissionNotesState, val: boolean) {
@@ -112,6 +111,17 @@ function SET_FEEDBACK_LABELS(state: SubmissionNotesState, labels: number[]) {
   state.changedLabels = true
   state.updatedFeedback.labels = labels
 }
+function ADD_FEEDBACK_LABEL(state: SubmissionNotesState, label: number) {
+  state.changedLabels = true
+  state.updatedFeedback.labels.push(label)
+}
+function REMOVE_FEEDBACK_LABEL(state: SubmissionNotesState, label: number) {
+  state.changedLabels = true
+  const tmp = state.updatedFeedback.labels.filter((val) => {
+    return val !== label
+  })
+  state.updatedFeedback.labels = tmp
+}
 function UPDATE_FEEDBACK_SCORE(state: SubmissionNotesState, score: number) {
   state.updatedFeedback.score = score
 }
@@ -193,6 +203,8 @@ export const SubmissionNotes = {
   SET_ORIG_FEEDBACK: mb.commit(SET_ORIG_FEEDBACK),
   SET_SHOW_FEEDBACK: mb.commit(SET_SHOW_FEEDBACK),
   SET_FEEDBACK_LABELS: mb.commit(SET_FEEDBACK_LABELS),
+  ADD_FEEDBACK_LABEL: mb.commit(ADD_FEEDBACK_LABEL),
+  REMOVE_FEEDBACK_LABEL: mb.commit(REMOVE_FEEDBACK_LABEL),
   UPDATE_FEEDBACK_LINE: mb.commit(UPDATE_FEEDBACK_LINE),
   UPDATE_FEEDBACK_SCORE: mb.commit(UPDATE_FEEDBACK_SCORE),
   DELETE_FEEDBACK_LINE: mb.commit(DELETE_FEEDBACK_LINE),
-- 
GitLab