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
Nelson Chan
3 years ago
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