Browse Source
			
			
			
			
				
		WIP: add color column, show tags WIP: Improve TagsManager styling & workflow WIP: Improve styling & validation, use translation WIP: Complete TagsManager functionality WIP: Add tags display in monitorList & Details Fix: update tags list after edit Fix: slightly improve tags styling Fix: Improve mobile UI Fix: Fix tags not showing on create monitor Fix: bring existingTags inside tagsManager Fix: remove unused tags prop Fix: Fix formatting, bump db versionpull/278/head
				 12 changed files with 681 additions and 9 deletions
			
			
		| @ -0,0 +1,19 @@ | |||
| -- You should not modify if this have pushed to Github, unless it does serious wrong with the db. | |||
| CREATE TABLE tag ( | |||
| 	id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | |||
| 	name VARCHAR(255) NOT NULL, | |||
|     color VARCHAR(255) NOT NULL, | |||
| 	created_date DATETIME DEFAULT (DATETIME('now')) NOT NULL | |||
| ); | |||
| 
 | |||
| CREATE TABLE monitor_tag ( | |||
| 	id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | |||
| 	monitor_id INTEGER NOT NULL, | |||
| 	tag_id INTEGER NOT NULL, | |||
| 	value TEXT, | |||
| 	CONSTRAINT FK_tag FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE ON UPDATE CASCADE, | |||
| 	CONSTRAINT FK_monitor FOREIGN KEY (monitor_id) REFERENCES monitor(id) ON DELETE CASCADE ON UPDATE CASCADE | |||
| ); | |||
| 
 | |||
| CREATE INDEX monitor_tag_monitor_id_index ON monitor_tag (monitor_id); | |||
| CREATE INDEX monitor_tag_tag_id_index ON monitor_tag (tag_id); | |||
| @ -0,0 +1,13 @@ | |||
| const { BeanModel } = require("redbean-node/dist/bean-model"); | |||
| 
 | |||
| class Tag extends BeanModel { | |||
|     toJSON() { | |||
|         return { | |||
|             id: this._id, | |||
|             name: this._name, | |||
|             color: this._color, | |||
|         }; | |||
|     } | |||
| } | |||
| 
 | |||
| module.exports = Tag; | |||
| @ -0,0 +1,68 @@ | |||
| <template> | |||
|     <div class="tag-wrapper rounded d-inline-flex" | |||
|          :class="{ 'px-3': size == 'normal', | |||
|                    'py-1': size == 'normal', | |||
|                    'm-2': size == 'normal', | |||
|                    'px-2': size == 'sm', | |||
|                    'py-0': size == 'sm', | |||
|                    'm-1': size == 'sm', | |||
|          }" | |||
|          :style="{ backgroundColor: item.color, fontSize: size == 'sm' ? '0.7em' : '1em' }" | |||
|     > | |||
|         <span class="tag-text">{{ displayText }}</span> | |||
|         <span v-if="remove != null" class="ps-1 btn-remove" @click="remove(item)"> | |||
|             <font-awesome-icon icon="times" /> | |||
|         </span> | |||
|     </div> | |||
| </template> | |||
| 
 | |||
| <script> | |||
| export default { | |||
|     props: { | |||
|         item: { | |||
|             type: Object, | |||
|             required: true, | |||
|         }, | |||
|         remove: { | |||
|             type: Function, | |||
|             default: null, | |||
|         }, | |||
|         size: { | |||
|             type: String, | |||
|             default: "normal", | |||
|         } | |||
|     }, | |||
|     computed: { | |||
|         displayText() { | |||
|             if (this.item.value == "") { | |||
|                 return this.item.name; | |||
|             } else { | |||
|                 return `${this.item.name}: ${this.item.value}`; | |||
|             } | |||
|         } | |||
|     } | |||
| } | |||
| </script> | |||
| 
 | |||
| <style scoped> | |||
| .tag-wrapper { | |||
|     color: white; | |||
| } | |||
| 
 | |||
| .tag-text { | |||
|     padding-bottom: 1px !important; | |||
|     text-overflow: ellipsis; | |||
|     overflow: hidden; | |||
|     white-space: nowrap; | |||
| } | |||
| 
 | |||
| .btn-remove { | |||
|     font-size: 0.9em; | |||
|     line-height: 24px; | |||
|     opacity: 0.3; | |||
| } | |||
| 
 | |||
| .btn-remove:hover { | |||
|     opacity: 1; | |||
| } | |||
| </style> | |||
| @ -0,0 +1,313 @@ | |||
| <template> | |||
|     <div> | |||
|         <h4 class="mb-3">{{ $t("Tags") }}</h4> | |||
|         <div class="mb-3 p-1"> | |||
|             <tag | |||
|                 v-for="item in selectedTags" | |||
|                 :key="item.id" | |||
|                 :item="item" | |||
|                 :remove="deleteTag" | |||
|             /> | |||
|         </div> | |||
|         <div> | |||
|             <vue-multiselect | |||
|                 v-model="newDraftTag.select" | |||
|                 class="mb-2" | |||
|                 :options="tagOptions" | |||
|                 :multiple="false" | |||
|                 :searchable="true" | |||
|                 :placeholder="$t('Add New below or Select...')" | |||
|                 track-by="id" | |||
|                 label="name" | |||
|             > | |||
|                 <template #option="{ option }"> | |||
|                     <div class="mx-2 py-1 px-3 rounded d-inline-flex" | |||
|                          style="margin-top: -5px; margin-bottom: -5px; height: 24px;" | |||
|                          :style="{ color: textColor(option), backgroundColor: option.color + ' !important' }" | |||
|                     > | |||
|                         <span> | |||
|                             {{ option.name }}</span> | |||
|                     </div> | |||
|                 </template> | |||
|                 <template #singleLabel="{ option }"> | |||
|                     <div class="py-1 px-3 rounded d-inline-flex" | |||
|                          style="height: 24px;" | |||
|                          :style="{ color: textColor(option), backgroundColor: option.color + ' !important' }" | |||
|                     > | |||
|                         <span>{{ option.name }}</span> | |||
|                     </div> | |||
|                 </template> | |||
|             </vue-multiselect> | |||
|             <div v-if="newDraftTag.select?.id == null" class="d-flex mb-2"> | |||
|                 <div class="w-50 pe-2"> | |||
|                     <input v-model="newDraftTag.name" class="form-control" :class="{'is-invalid': newDraftTag.nameInvalid}" placeholder="name" /> | |||
|                     <div class="invalid-feedback"> | |||
|                         {{ $t("Tag with this name already exist.") }} | |||
|                     </div> | |||
|                 </div> | |||
|                 <div class="w-50 ps-2"> | |||
|                     <vue-multiselect | |||
|                         v-model="newDraftTag.color" | |||
|                         :options="colorOptions" | |||
|                         :multiple="false" | |||
|                         :searchable="true" | |||
|                         :placeholder="$t('color')" | |||
|                         track-by="color" | |||
|                         label="name" | |||
|                         select-label="" | |||
|                         deselect-label="" | |||
|                     > | |||
|                         <template #option="{ option }"> | |||
|                             <div class="mx-2 py-1 px-3 rounded d-inline-flex" | |||
|                                  style="height: 24px; color: white;" | |||
|                                  :style="{ backgroundColor: option.color + ' !important' }" | |||
|                             > | |||
|                                 <span>{{ option.name }}</span> | |||
|                             </div> | |||
|                         </template> | |||
|                         <template #singleLabel="{ option }"> | |||
|                             <div class="py-1 px-3 rounded d-inline-flex" | |||
|                                  style="height: 24px; color: white;" | |||
|                                  :style="{ backgroundColor: option.color + ' !important' }" | |||
|                             > | |||
|                                 <span>{{ option.name }}</span> | |||
|                             </div> | |||
|                         </template> | |||
|                     </vue-multiselect> | |||
|                 </div> | |||
|             </div> | |||
|             <input v-model="newDraftTag.value" class="form-control mb-2" :placeholder="$t('value (optional)')" /> | |||
|             <div class="mb-2"> | |||
|                 <button | |||
|                     type="button" | |||
|                     class="btn btn-secondary float-end" | |||
|                     :disabled="processing || newDraftTag.invalid" | |||
|                     @click.stop="addDraftTag" | |||
|                 > | |||
|                     {{ $t("Add") }} | |||
|                 </button> | |||
|             </div> | |||
|         </div> | |||
|     </div> | |||
| </template> | |||
| 
 | |||
| <script> | |||
| import VueMultiselect from "vue-multiselect"; | |||
| import Tag from "../components/Tag.vue"; | |||
| import { useToast } from "vue-toastification" | |||
| const toast = useToast() | |||
| 
 | |||
| export default { | |||
|     components: { | |||
|         Tag, | |||
|         VueMultiselect, | |||
|     }, | |||
|     props: { | |||
|         preSelectedTags: { | |||
|             type: Array, | |||
|             default: () => [], | |||
|         }, | |||
|     }, | |||
|     data() { | |||
|         return { | |||
|             existingTags: [], | |||
|             processing: false, | |||
|             newTags: [], | |||
|             deleteTags: [], | |||
|             newDraftTag: { | |||
|                 name: null, | |||
|                 select: null, | |||
|                 color: null, | |||
|                 value: "", | |||
|                 invalid: true, | |||
|                 nameInvalid: false, | |||
|             }, | |||
|         }; | |||
|     }, | |||
|     computed: { | |||
|         tagOptions() { | |||
|             return this.existingTags; | |||
|         }, | |||
|         selectedTags() { | |||
|             return this.preSelectedTags.concat(this.newTags).filter(tag => !this.deleteTags.find(monitorTag => monitorTag.id == tag.id)); | |||
|         }, | |||
|         colorOptions() { | |||
|             return [ | |||
|                 { name: this.$t("Gray"), | |||
|                     color: "#4B5563" }, | |||
|                 { name: this.$t("Red"), | |||
|                     color: "#DC2626" }, | |||
|                 { name: this.$t("Orange"), | |||
|                     color: "#D97706" }, | |||
|                 { name: this.$t("Green"), | |||
|                     color: "#059669" }, | |||
|                 { name: this.$t("Blue"), | |||
|                     color: "#2563EB" }, | |||
|                 { name: this.$t("Indigo"), | |||
|                     color: "#4F46E5" }, | |||
|                 { name: this.$t("Purple"), | |||
|                     color: "#7C3AED" }, | |||
|                 { name: this.$t("Pink"), | |||
|                     color: "#DB2777" }, | |||
|             ] | |||
|         } | |||
|     }, | |||
|     watch: { | |||
|         "newDraftTag.select": function (newSelected) { | |||
|             this.newDraftTag.select = newSelected; | |||
|             this.validateDraftTag(); | |||
|         }, | |||
|         "newDraftTag.name": function (newName) { | |||
|             this.newDraftTag.name = newName.trim(); | |||
|             this.validateDraftTag(); | |||
|         }, | |||
|         "newDraftTag.color": function (newColor) { | |||
|             this.newDraftTag.color = newColor; | |||
|             this.validateDraftTag(); | |||
|         }, | |||
|     }, | |||
|     mounted() { | |||
|         this.getExistingTags(); | |||
|     }, | |||
|     methods: { | |||
|         getExistingTags() { | |||
|             this.$root.getSocket().emit("getTags", (res) => { | |||
|                 if (res.ok) { | |||
|                     this.existingTags = res.tags; | |||
|                 } else { | |||
|                     toast.error(res.msg) | |||
|                 } | |||
|             }); | |||
|         }, | |||
|         deleteTag(item) { | |||
|             if (item.new) { | |||
|                 // Undo Adding a new Tag | |||
|                 this.newTags = this.newTags.filter(tag => tag.name != item.name && tag.value != item.value); | |||
|             } else { | |||
|                 // Remove an Existing Tag | |||
|                 this.deleteTags.push(item); | |||
|             } | |||
|         }, | |||
|         validateDraftTag() { | |||
|             if (this.newDraftTag.select != null) { | |||
|                 // Select an existing tag, no need to validate | |||
|                 this.newDraftTag.invalid = false; | |||
|             } else if (this.existingTags.filter(tag => tag.name === this.newDraftTag.name).length > 0) { | |||
|                 // Try to create new tag with existing name | |||
|                 this.newDraftTag.nameInvalid = true; | |||
|                 this.newDraftTag.invalid = true; | |||
|             } else if (this.newDraftTag.color == null || this.newDraftTag.name === "") { | |||
|                 // Missing form inputs | |||
|                 this.newDraftTag.nameInvalid = false; | |||
|                 this.newDraftTag.invalid = true; | |||
|             } else { | |||
|                 // Looks valid | |||
|                 this.newDraftTag.invalid = false; | |||
|                 this.newDraftTag.nameInvalid = false; | |||
|             } | |||
|         }, | |||
|         textColor(option) { | |||
|             if (option.color) { | |||
|                 return "white"; | |||
|             } else { | |||
|                 return this.$root.theme === "light" ? "var(--bs-body-color)" : "inherit"; | |||
|             } | |||
|         }, | |||
|         addDraftTag() { | |||
|             console.log("Adding Draft Tag: ", this.newDraftTag); | |||
|             if (this.newDraftTag.select != null) { | |||
|                 // Add an existing Tag | |||
|                 this.newTags.push({ | |||
|                     id: this.newDraftTag.select.id, | |||
|                     color: this.newDraftTag.select.color, | |||
|                     name: this.newDraftTag.select.name, | |||
|                     value: this.newDraftTag.value, | |||
|                     new: true, | |||
|                 }) | |||
|             } else { | |||
|                 // Add new Tag | |||
|                 this.newTags.push({ | |||
|                     color: this.newDraftTag.color.color, | |||
|                     name: this.newDraftTag.name, | |||
|                     value: this.newDraftTag.value, | |||
|                     new: true, | |||
|                 }) | |||
|             } | |||
|         }, | |||
|         addTagAsync(newTag) { | |||
|             return new Promise((resolve) => { | |||
|                 this.$root.getSocket().emit("addTag", newTag, resolve); | |||
|             }); | |||
|         }, | |||
|         addMonitorTagAsync(tagId, monitorId, value) { | |||
|             return new Promise((resolve) => { | |||
|                 this.$root.getSocket().emit("addMonitorTag", tagId, monitorId, value, resolve); | |||
|             }); | |||
|         }, | |||
|         deleteMonitorTagAsync(tagId, monitorId) { | |||
|             return new Promise((resolve) => { | |||
|                 this.$root.getSocket().emit("deleteMonitorTag", tagId, monitorId, resolve); | |||
|             }); | |||
|         }, | |||
|         async submit(monitorId) { | |||
|             console.log(`Submitting tag changes for monitor ${monitorId}...`); | |||
|             this.processing = true; | |||
| 
 | |||
|             for (const newTag of this.newTags) { | |||
|                 let tagId; | |||
|                 if (newTag.id == null) { | |||
|                     let newTagResult; | |||
|                     await this.addTagAsync(newTag).then((res) => { | |||
|                         if (!res.ok) { | |||
|                             toast.error(res.msg); | |||
|                             newTagResult = false; | |||
|                         } | |||
|                         newTagResult = res.tag; | |||
|                     }); | |||
|                     if (!newTagResult) { | |||
|                         // abort | |||
|                         this.processing = false; | |||
|                         return; | |||
|                     } | |||
|                     tagId = newTagResult.id; | |||
|                 } else { | |||
|                     tagId = newTag.id; | |||
|                 } | |||
| 
 | |||
|                 let newMonitorTagResult; | |||
|                 await this.addMonitorTagAsync(tagId, monitorId, newTag.value).then((res) => { | |||
|                     if (!res.ok) { | |||
|                         toast.error(res.msg); | |||
|                         newMonitorTagResult = false; | |||
|                     } | |||
|                     newMonitorTagResult = true; | |||
|                 }); | |||
|                 if (!newMonitorTagResult) { | |||
|                     // abort | |||
|                     this.processing = false; | |||
|                     return; | |||
|                 } | |||
|             } | |||
| 
 | |||
|             for (const deleteTag of this.deleteTags) { | |||
|                 let deleteMonitorTagResult; | |||
|                 await this.deleteMonitorTagAsync(deleteTag.tag_id, deleteTag.monitor_id).then((res) => { | |||
|                     if (!res.ok) { | |||
|                         toast.error(res.msg); | |||
|                         deleteMonitorTagResult = false; | |||
|                     } | |||
|                     deleteMonitorTagResult = true; | |||
|                 }); | |||
|                 if (!deleteMonitorTagResult) { | |||
|                     // abort | |||
|                     this.processing = false; | |||
|                     return; | |||
|                 } | |||
|             } | |||
| 
 | |||
|             this.getExistingTags(); | |||
|             this.processing = false; | |||
|         } | |||
|     }, | |||
| }; | |||
| </script> | |||
					Loading…
					
					
				
		Reference in new issue