Added tags
This commit is contained in:
parent
1d5df2a4b1
commit
b6d51eaae5
11 changed files with 349 additions and 15 deletions
|
@ -13,6 +13,7 @@ class AddTagToRecordBranch extends ApiBranch
|
||||||
|
|
||||||
function post(ParamCleaner $params)
|
function post(ParamCleaner $params)
|
||||||
{
|
{
|
||||||
|
// !! Security issue: Could combined arbitrary tag and record, not restricted by user
|
||||||
$record_id = $params->get("record_id");
|
$record_id = $params->get("record_id");
|
||||||
$tag_id = $params->get("tag_id");
|
$tag_id = $params->get("tag_id");
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ class RemoveTagFromRecordBranch extends ApiBranch
|
||||||
|
|
||||||
function post(ParamCleaner $params)
|
function post(ParamCleaner $params)
|
||||||
{
|
{
|
||||||
|
// !! Security issue: Could combined arbitrary tag and record, not restricted by user
|
||||||
$record_id = $params->get("record_id");
|
$record_id = $params->get("record_id");
|
||||||
$tag_id = $params->get("tag_id");
|
$tag_id = $params->get("tag_id");
|
||||||
|
|
||||||
|
|
|
@ -27,6 +27,8 @@ class JsonBuilder
|
||||||
"duration" => "",
|
"duration" => "",
|
||||||
"user_id" => "",
|
"user_id" => "",
|
||||||
"project_id" => "",
|
"project_id" => "",
|
||||||
|
"running" => "",
|
||||||
|
"tags" => "",
|
||||||
"start_device_id" => ""
|
"start_device_id" => ""
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
69
src/components/forms/FormTagAdd.vue
Normal file
69
src/components/forms/FormTagAdd.vue
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
<template>
|
||||||
|
<form @submit="submitForm">
|
||||||
|
<b-input-group id="form">
|
||||||
|
<b-form-input id="name" v-model="form.name" placeholder="Tag name" trim>
|
||||||
|
</b-form-input>
|
||||||
|
<b-input-group-append>
|
||||||
|
<b-button variant="outline-secondary" type="submit" :disabled="working">
|
||||||
|
<b-spinner v-if="working" small />
|
||||||
|
Add tag
|
||||||
|
</b-button>
|
||||||
|
</b-input-group-append>
|
||||||
|
</b-input-group>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import store from "@/store";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "FormTagAdd",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
name: ""
|
||||||
|
},
|
||||||
|
working: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* Submits the form. Assupmtion: Form is valid, based on required flags.
|
||||||
|
*/
|
||||||
|
submitForm: function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (this.form.name == "") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.failed = false;
|
||||||
|
this.working = true;
|
||||||
|
|
||||||
|
// Try to login
|
||||||
|
store
|
||||||
|
.dispatch("addTag", {
|
||||||
|
name: this.form.name
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.form.name = "";
|
||||||
|
this.working = false;
|
||||||
|
this.$emit("submit");
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
console.log(e);
|
||||||
|
|
||||||
|
this.working = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
#form
|
||||||
|
max-width: 20rem
|
||||||
|
margin: auto
|
||||||
|
</style>
|
|
@ -26,8 +26,16 @@
|
||||||
{{ getDurationTimestamp(data.item.duration) }}
|
{{ getDurationTimestamp(data.item.duration) }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #cell(tags)="data">
|
||||||
|
<JugglTagField :recordId="data.item.record_id" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell(details)="data">
|
<template #cell(details)="data">
|
||||||
<b-button size="sm" @click="data.toggleDetails" variant="outline-dark">
|
<b-button
|
||||||
|
size="sm"
|
||||||
|
@click="data.toggleDetails"
|
||||||
|
variant="outline-secondary"
|
||||||
|
>
|
||||||
<b-icon class="icon-btn" icon="gear" />
|
<b-icon class="icon-btn" icon="gear" />
|
||||||
</b-button>
|
</b-button>
|
||||||
</template>
|
</template>
|
||||||
|
@ -55,14 +63,16 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import store from "@/store";
|
|
||||||
import FormRecordDetails from "@/components/forms/FormRecordDetails";
|
import FormRecordDetails from "@/components/forms/FormRecordDetails";
|
||||||
|
import JugglTagField from "@/components/juggl/JugglTagField";
|
||||||
import { helperService } from "@/services/helper.service.js";
|
import { helperService } from "@/services/helper.service.js";
|
||||||
|
import store from "@/store";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "JugglRecordsList",
|
name: "JugglRecordsList",
|
||||||
components: {
|
components: {
|
||||||
FormRecordDetails
|
FormRecordDetails,
|
||||||
|
JugglTagField
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
records: {
|
records: {
|
||||||
|
@ -96,6 +106,10 @@ export default {
|
||||||
key: "duration",
|
key: "duration",
|
||||||
label: "Duration"
|
label: "Duration"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "tags",
|
||||||
|
label: "Tags"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "details",
|
key: "details",
|
||||||
label: "Details"
|
label: "Details"
|
||||||
|
@ -104,7 +118,7 @@ export default {
|
||||||
runningFields: [
|
runningFields: [
|
||||||
{
|
{
|
||||||
key: "stop",
|
key: "stop",
|
||||||
label: "Stop"
|
label: "Finish"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
145
src/components/juggl/JugglTagField.vue
Normal file
145
src/components/juggl/JugglTagField.vue
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
<template>
|
||||||
|
<div :id="containerId" class="tag-container">
|
||||||
|
<div
|
||||||
|
class="tag-item"
|
||||||
|
v-for="tag in addedTags"
|
||||||
|
:key="tag.record_tag_id"
|
||||||
|
@click="() => removeTag(tag)"
|
||||||
|
>
|
||||||
|
{{ tag.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :id="btnId">
|
||||||
|
<b-icon icon="plus" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<b-popover
|
||||||
|
:target="btnId"
|
||||||
|
triggers="click"
|
||||||
|
placement="auto"
|
||||||
|
:container="containerId"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="tag-container">
|
||||||
|
<div
|
||||||
|
class="tag-item"
|
||||||
|
v-for="tag in unusedTags"
|
||||||
|
:key="tag.record_tag_id"
|
||||||
|
@click="() => addTag(tag)"
|
||||||
|
>
|
||||||
|
{{ tag.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<FormTagAdd @submit="() => reloadTags()" />
|
||||||
|
</div>
|
||||||
|
</b-popover>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import FormTagAdd from "@/components/forms/FormTagAdd";
|
||||||
|
import store from "@/store";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "JugglTagField",
|
||||||
|
components: {
|
||||||
|
FormTagAdd
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
recordId: {
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
record: {}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
created: function() {
|
||||||
|
this.record = store.getters.getRecordById(this.recordId);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getTagById: function(id) {
|
||||||
|
return store.getters.getTagById(id);
|
||||||
|
},
|
||||||
|
addTag: function(tag) {
|
||||||
|
this.record.tags.push(tag);
|
||||||
|
store.dispatch("addTagToRecord", {
|
||||||
|
tagId: tag.record_tag_id,
|
||||||
|
recordId: this.recordId
|
||||||
|
});
|
||||||
|
},
|
||||||
|
reloadTags: function() {
|
||||||
|
store.dispatch("loadTags");
|
||||||
|
},
|
||||||
|
removeTag: function(tag) {
|
||||||
|
const index = this.record.tags.indexOf(tag);
|
||||||
|
if (index > -1) {
|
||||||
|
this.record.tags.splice(index, 1);
|
||||||
|
store.dispatch("removeTagFromRecord", {
|
||||||
|
tagId: tag.record_tag_id,
|
||||||
|
recordId: this.recordId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
unusedTags: function() {
|
||||||
|
return Object.values(this.allTags).filter(
|
||||||
|
tag => !this.usedTagIds.includes(tag.record_tag_id)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
allTags: function() {
|
||||||
|
return store.getters.tags;
|
||||||
|
},
|
||||||
|
addedTags: function() {
|
||||||
|
return this.record.tags;
|
||||||
|
},
|
||||||
|
usedTagIds: function() {
|
||||||
|
var ids = [];
|
||||||
|
Object.values(this.addedTags).forEach(tag => {
|
||||||
|
ids.push(tag.record_tag_id);
|
||||||
|
});
|
||||||
|
return ids;
|
||||||
|
},
|
||||||
|
containerId: function() {
|
||||||
|
return "added-tags-for-" + this.record.record_id;
|
||||||
|
},
|
||||||
|
btnId: function() {
|
||||||
|
return "add-tags-for-" + this.record.record_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="sass" scoped>
|
||||||
|
@import '@/style/theme.sass'
|
||||||
|
|
||||||
|
.tag-container
|
||||||
|
display: flex
|
||||||
|
flex-direction: row
|
||||||
|
flex-wrap: wrap
|
||||||
|
justify-content: left
|
||||||
|
align-content: flex-start
|
||||||
|
|
||||||
|
> *
|
||||||
|
margin: 2px
|
||||||
|
border-radius: 5px
|
||||||
|
padding: 1px 6px
|
||||||
|
text-align: center
|
||||||
|
cursor: pointer
|
||||||
|
border: 1px solid $grey
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background-color: $grey
|
||||||
|
color: $background-primary
|
||||||
|
|
||||||
|
> .tag-item
|
||||||
|
border: 1px solid $primary
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background-color: $primary
|
||||||
|
color: $white
|
||||||
|
</style>
|
|
@ -28,6 +28,15 @@ export const jugglService = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getTags() {
|
||||||
|
return apiService.post("/getRecordTags.php").then(r => {
|
||||||
|
return {
|
||||||
|
data: r.data,
|
||||||
|
msg: ""
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
getRecord(recordId) {
|
getRecord(recordId) {
|
||||||
return apiService
|
return apiService
|
||||||
.post("/getRecord.php", {
|
.post("/getRecord.php", {
|
||||||
|
@ -109,6 +118,47 @@ export const jugglService = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addTag(name) {
|
||||||
|
return apiService
|
||||||
|
.post("/addRecordTag.php", {
|
||||||
|
tag_name: name
|
||||||
|
})
|
||||||
|
.then(r => {
|
||||||
|
return {
|
||||||
|
data: r.data,
|
||||||
|
msg: ""
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
addTagToRecord(tagId, recordId) {
|
||||||
|
return apiService
|
||||||
|
.post("/addTagToRecord.php", {
|
||||||
|
tag_id: tagId,
|
||||||
|
record_id: recordId
|
||||||
|
})
|
||||||
|
.then(r => {
|
||||||
|
return {
|
||||||
|
data: r.data,
|
||||||
|
msg: ""
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTagFromRecord(tagId, recordId) {
|
||||||
|
return apiService
|
||||||
|
.post("/removeTagFromRecord.php", {
|
||||||
|
tag_id: tagId,
|
||||||
|
record_id: recordId
|
||||||
|
})
|
||||||
|
.then(r => {
|
||||||
|
return {
|
||||||
|
data: r.data,
|
||||||
|
msg: ""
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
startRecord(projectId, startTime = null) {
|
startRecord(projectId, startTime = null) {
|
||||||
if (startTime == null) startTime = new Date();
|
if (startTime == null) startTime = new Date();
|
||||||
return apiService
|
return apiService
|
||||||
|
@ -142,11 +192,11 @@ export const jugglService = {
|
||||||
|
|
||||||
function processRecords(data) {
|
function processRecords(data) {
|
||||||
Object.values(data.records).forEach(rec => {
|
Object.values(data.records).forEach(rec => {
|
||||||
rec.running = rec.end_time === null;
|
|
||||||
|
|
||||||
if (rec.running) {
|
if (rec.running) {
|
||||||
rec.duration = helperService.calcDurationInSeconds(rec.start_time);
|
rec.duration = helperService.calcDurationInSeconds(rec.start_time);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rec.tags = Object.values(rec.tags);
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,8 +3,9 @@ import { jugglService } from "@/services/juggl.service.js";
|
||||||
export const juggl = {
|
export const juggl = {
|
||||||
state: {
|
state: {
|
||||||
apiUrl: "https://juggl.giller.dev/api",
|
apiUrl: "https://juggl.giller.dev/api",
|
||||||
projects: {},
|
projects: [],
|
||||||
records: {},
|
records: [],
|
||||||
|
tags: [],
|
||||||
user: undefined,
|
user: undefined,
|
||||||
auth: undefined,
|
auth: undefined,
|
||||||
recordsLimit: 0
|
recordsLimit: 0
|
||||||
|
@ -16,6 +17,9 @@ export const juggl = {
|
||||||
setRecords(state, records) {
|
setRecords(state, records) {
|
||||||
state.records = records;
|
state.records = records;
|
||||||
},
|
},
|
||||||
|
setTags(state, tags) {
|
||||||
|
state.tags = tags;
|
||||||
|
},
|
||||||
setRecordsLimit(state, limit) {
|
setRecordsLimit(state, limit) {
|
||||||
state.recordsLimit = limit;
|
state.recordsLimit = limit;
|
||||||
},
|
},
|
||||||
|
@ -46,6 +50,7 @@ export const juggl = {
|
||||||
isLoggedIn: state => !!state.auth,
|
isLoggedIn: state => !!state.auth,
|
||||||
records: state => state.records,
|
records: state => state.records,
|
||||||
projects: state => state.projects,
|
projects: state => state.projects,
|
||||||
|
tags: state => state.tags,
|
||||||
projectIds: state => {
|
projectIds: state => {
|
||||||
var projectIds = [];
|
var projectIds = [];
|
||||||
Object.values(state.projects).forEach(project => {
|
Object.values(state.projects).forEach(project => {
|
||||||
|
@ -84,6 +89,9 @@ export const juggl = {
|
||||||
project => project.project_id === id
|
project => project.project_id === id
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
getTagById: (state, getters) => id => {
|
||||||
|
return Object.values(getters.tags).find(tag => tag.record_tag_id === id);
|
||||||
|
},
|
||||||
getRecordById: (state, getters) => id => {
|
getRecordById: (state, getters) => id => {
|
||||||
return Object.values(getters.records).find(
|
return Object.values(getters.records).find(
|
||||||
record => record.record_id === id
|
record => record.record_id === id
|
||||||
|
@ -101,6 +109,11 @@ export const juggl = {
|
||||||
commit("setProjects", r.data.projects);
|
commit("setProjects", r.data.projects);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
loadTags({ commit }) {
|
||||||
|
return jugglService.getTags().then(r => {
|
||||||
|
commit("setTags", r.data.record_tags);
|
||||||
|
});
|
||||||
|
},
|
||||||
loadUser({ commit }) {
|
loadUser({ commit }) {
|
||||||
return jugglService
|
return jugglService
|
||||||
.getUser()
|
.getUser()
|
||||||
|
@ -196,6 +209,39 @@ export const juggl = {
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
addTag(context, { name }) {
|
||||||
|
return jugglService
|
||||||
|
.addTag(name)
|
||||||
|
.catch(() => {
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.dispatch("loadTags");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
addTagToRecord(context, { tagId, recordId }) {
|
||||||
|
return jugglService
|
||||||
|
.addTagToRecord(tagId, recordId)
|
||||||
|
.catch(() => {
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// TODO: Manualy add tag
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
removeTagFromRecord(context, { tagId, recordId }) {
|
||||||
|
return jugglService
|
||||||
|
.removeTagFromRecord(tagId, recordId)
|
||||||
|
.catch(() => {
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// TODO: Manualy remove tag
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
},
|
||||||
startRecord(context, projectId) {
|
startRecord(context, projectId) {
|
||||||
if (projectId === undefined) {
|
if (projectId === undefined) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -62,9 +62,12 @@ a
|
||||||
border-radius: 0
|
border-radius: 0
|
||||||
background: #0000
|
background: #0000
|
||||||
|
|
||||||
.dropdown-menu
|
.bs-popover-bottom > .arrow::after, .bs-popover-auto[x-placement^="bottom"] > .arrow::after
|
||||||
background-color: $background-primary !important
|
border-bottom-color: $white !important
|
||||||
border: 1px solid $primary !important
|
|
||||||
|
.dropdown-menu, .b-popover
|
||||||
|
background-color: $background-secondary !important
|
||||||
|
border: 1px solid $white !important
|
||||||
color: $primary
|
color: $primary
|
||||||
|
|
||||||
.b-time *,
|
.b-time *,
|
||||||
|
@ -134,6 +137,9 @@ div.card
|
||||||
pre
|
pre
|
||||||
color: $font-primary !important
|
color: $font-primary !important
|
||||||
|
|
||||||
|
hr
|
||||||
|
border-color: $white !important
|
||||||
|
|
||||||
|
|
||||||
// Import Bootstrap and BootstrapVue source SCSS files
|
// Import Bootstrap and BootstrapVue source SCSS files
|
||||||
@import '~bootstrap/scss/bootstrap.scss'
|
@import '~bootstrap/scss/bootstrap.scss'
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<LayoutNavbarPrivate>
|
<LayoutNavbarPrivate title="History">
|
||||||
<section>
|
<section>
|
||||||
<h2 class="center">History</h2>
|
<div class="center" v-if="working">
|
||||||
<div class="center">
|
<b-spinner></b-spinner>
|
||||||
<b-spinner v-if="working"></b-spinner>
|
|
||||||
</div>
|
</div>
|
||||||
<JugglRecordsList :records="finishedRecords" v-if="!working" />
|
<JugglRecordsList :records="finishedRecords" v-if="!working" />
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -53,6 +53,7 @@ export default {
|
||||||
},
|
},
|
||||||
created: () => {
|
created: () => {
|
||||||
store.dispatch("loadProjects");
|
store.dispatch("loadProjects");
|
||||||
|
store.dispatch("loadTags");
|
||||||
store.dispatch("loadRunningRecords");
|
store.dispatch("loadRunningRecords");
|
||||||
store.dispatch("loadRecords", { limit: 10, finished: true });
|
store.dispatch("loadRecords", { limit: 10, finished: true });
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue