Louis Lam
3 years ago
committed by
GitHub
12 changed files with 871 additions and 28 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,73 @@ |
|||
<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 lang="scss" scoped> |
|||
.tag-wrapper { |
|||
color: white; |
|||
opacity: 0.85; |
|||
|
|||
.dark & { |
|||
opacity: 1; |
|||
} |
|||
} |
|||
|
|||
.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,405 @@ |
|||
<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 class="p-1"> |
|||
<button |
|||
type="button" |
|||
class="btn btn-outline-secondary btn-add" |
|||
:disabled="processing" |
|||
@click.stop="showAddDialog" |
|||
> |
|||
<font-awesome-icon class="me-1" icon="plus" /> {{ $t("Add") }} |
|||
</button> |
|||
</div> |
|||
<div ref="modal" class="modal fade" tabindex="-1"> |
|||
<div class="modal-dialog modal-dialog-centered"> |
|||
<div class="modal-content"> |
|||
<div class="modal-body"> |
|||
<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?.name == null" class="d-flex mb-2"> |
|||
<div class="w-50 pe-2"> |
|||
<input v-model="newDraftTag.name" class="form-control" |
|||
:class="{'is-invalid': validateDraftTag.nameInvalid}" |
|||
:placeholder="$t('name')" |
|||
@keydown.enter.prevent="onEnter" |
|||
/> |
|||
<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> |
|||
<div class="mb-2"> |
|||
<input v-model="newDraftTag.value" class="form-control" |
|||
:class="{'is-invalid': validateDraftTag.valueInvalid}" |
|||
:placeholder="$t('value (optional)')" |
|||
@keydown.enter.prevent="onEnter" |
|||
/> |
|||
<div class="invalid-feedback"> |
|||
{{ $t("Tag with this value already exist.") }} |
|||
</div> |
|||
</div> |
|||
<div class="mb-2"> |
|||
<button |
|||
type="button" |
|||
class="btn btn-secondary float-end" |
|||
:disabled="processing || validateDraftTag.invalid" |
|||
@click.stop="addDraftTag" |
|||
> |
|||
{{ $t("Add") }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { Modal } from "bootstrap"; |
|||
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 { |
|||
modal: null, |
|||
existingTags: [], |
|||
processing: false, |
|||
newTags: [], |
|||
deleteTags: [], |
|||
newDraftTag: { |
|||
name: null, |
|||
select: null, |
|||
color: null, |
|||
value: "", |
|||
invalid: true, |
|||
nameInvalid: false, |
|||
}, |
|||
}; |
|||
}, |
|||
computed: { |
|||
tagOptions() { |
|||
const tagOptions = this.existingTags; |
|||
for (const tag of this.newTags) { |
|||
if (!tagOptions.find(t => t.name == tag.name && t.color == tag.color)) { |
|||
tagOptions.push(tag); |
|||
} |
|||
} |
|||
return tagOptions; |
|||
}, |
|||
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" }, |
|||
] |
|||
}, |
|||
validateDraftTag() { |
|||
let nameInvalid = false; |
|||
let valueInvalid = false; |
|||
let invalid = true; |
|||
if (this.deleteTags.find(tag => tag.name == this.newDraftTag.select?.name && tag.value == this.newDraftTag.value)) { |
|||
// Undo removing a Tag |
|||
nameInvalid = false; |
|||
valueInvalid = false; |
|||
invalid = false; |
|||
} else if (this.existingTags.filter(tag => tag.name === this.newDraftTag.name).length > 0) { |
|||
// Try to create new tag with existing name |
|||
nameInvalid = true; |
|||
invalid = true; |
|||
} else if (this.newTags.concat(this.preSelectedTags).filter(tag => ( |
|||
tag.name == this.newDraftTag.select?.name && tag.value == this.newDraftTag.value |
|||
) || ( |
|||
tag.name == this.newDraftTag.name && tag.value == this.newDraftTag.value |
|||
)).length > 0) { |
|||
// Try to add a tag with existing name and value |
|||
valueInvalid = true; |
|||
invalid = true; |
|||
} else if (this.newDraftTag.select != null) { |
|||
// Select an existing tag, no need to validate |
|||
invalid = false; |
|||
valueInvalid = false; |
|||
} else if (this.newDraftTag.color == null || this.newDraftTag.name === "") { |
|||
// Missing form inputs |
|||
nameInvalid = false; |
|||
invalid = true; |
|||
} else { |
|||
// Looks valid |
|||
invalid = false; |
|||
nameInvalid = false; |
|||
valueInvalid = false; |
|||
} |
|||
return { |
|||
invalid, |
|||
nameInvalid, |
|||
valueInvalid, |
|||
} |
|||
}, |
|||
}, |
|||
mounted() { |
|||
this.modal = new Modal(this.$refs.modal); |
|||
this.getExistingTags(); |
|||
}, |
|||
methods: { |
|||
showAddDialog() { |
|||
this.modal.show(); |
|||
}, |
|||
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); |
|||
} |
|||
}, |
|||
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) { |
|||
if (this.deleteTags.find(tag => tag.name == this.newDraftTag.select.name && tag.value == this.newDraftTag.value)) { |
|||
// Undo removing a tag |
|||
this.deleteTags = this.deleteTags.filter(tag => !(tag.name == this.newDraftTag.select.name && tag.value == this.newDraftTag.value)); |
|||
} else { |
|||
// 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.trim(), |
|||
value: this.newDraftTag.value, |
|||
new: true, |
|||
}) |
|||
} |
|||
this.clearDraftTag(); |
|||
}, |
|||
clearDraftTag() { |
|||
this.newDraftTag = { |
|||
name: null, |
|||
select: null, |
|||
color: null, |
|||
value: "", |
|||
invalid: true, |
|||
nameInvalid: false, |
|||
}; |
|||
this.modal.hide(); |
|||
}, |
|||
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, value) { |
|||
return new Promise((resolve) => { |
|||
this.$root.getSocket().emit("deleteMonitorTag", tagId, monitorId, value, resolve); |
|||
}); |
|||
}, |
|||
onEnter() { |
|||
if (!this.validateDraftTag.invalid) { |
|||
this.addDraftTag(); |
|||
} |
|||
}, |
|||
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) { |
|||
// Create a New Tag |
|||
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; |
|||
// Assign the new ID to the tags of the same name & color |
|||
this.newTags.map(tag => { |
|||
if (tag.name == newTag.name && tag.color == newTag.color) { |
|||
tag.id = newTagResult.id; |
|||
} |
|||
}) |
|||
} else { |
|||
tagId = newTag.id; |
|||
} |
|||
|
|||
let newMonitorTagResult; |
|||
// Assign tag to monitor |
|||
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, deleteTag.value).then((res) => { |
|||
if (!res.ok) { |
|||
toast.error(res.msg); |
|||
deleteMonitorTagResult = false; |
|||
} |
|||
deleteMonitorTagResult = true; |
|||
}); |
|||
if (!deleteMonitorTagResult) { |
|||
// abort |
|||
this.processing = false; |
|||
return; |
|||
} |
|||
} |
|||
|
|||
this.getExistingTags(); |
|||
this.newTags = []; |
|||
this.deleteTags = []; |
|||
this.processing = false; |
|||
} |
|||
}, |
|||
}; |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.btn-add { |
|||
width: 100%; |
|||
} |
|||
|
|||
.modal-body { |
|||
padding: 1.5rem; |
|||
} |
|||
</style> |
Loading…
Reference in new issue