Move everything to models and services (#17)

This commit is contained in:
konrad 2019-03-02 10:25:10 +00:00 committed by Gitea
parent 8559d8bb97
commit 9b0c842ae1
50 changed files with 2165 additions and 1206 deletions

184
docs/models-services.md Normal file
View file

@ -0,0 +1,184 @@
# Models and services
The architecture of this web app is in general divided in two parts:
Models and services.
Services handle all "raw" requests, models contain data and methods to work with it.
A service takes (in most cases) a model and returns one.
## Table of Contents
* [Services](#services)
* [Requests](#requests)
* [Loading](#loading)
* [Factories](#factories)
* [Before Request](#before-request)
* [After Request?](#after-request-)
* [Models](#models)
* [Default Values](#default-values)
* [Constructor](#constructor)
* [Access Model Data](#access-to-the-data)
## Services
Services are located in `src/services`.
All services must inherit `AbstractService` which holds most of the methods.
A basic service can look like this:
```javascript
import AbstractService from './abstractService'
import ListModel from '../models/list'
export default class ListService extends AbstractService {
constructor() {
super({
getAll: '/lists',
get: '/lists/{id}',
create: '/namespaces/{namespaceID}/lists',
update: '/lists/{id}',
delete: '/lists/{id}',
})
}
modelFactory(data) {
return new ListModel(data)
}
}
```
The `constructor` calls its parent constructor and provides the paths to make the requests.
The parent constructor will take these and save them in the service.
All paths are optional. Calling a method which doesn't have a path defined will fail.
The placeholder values in the urls are replaced with the contens of variables with the same name in the
corresponding model (the one you pass to the functions).
#### Requests
Several request types are possible:
| Name | HTTP Method |
|------|-------------|
| `get` | `GET` |
| `getAll` | `GET` |
| `create` | `PUT` |
| `update` | `POST` |
| `delete` | `DELETE` |
Each method can take a model and optional url parameters as function parameters.
With the exception of `getAll()`, a model is always mandatory while parameters are not.
Each method returns a promise, so you can access a request result like so:
```javascript
service.getAll().then(result => {
// Do something with result
})
```
The result is a ready-to-use model returned by the model factory.
##### Loading
Each service has a `loading` property, provided by `AbstractModel`.
This property is a `boolean`, it is automatically set to `true` (with a 100ms delay to avoid flickering)
once the request is started and set to `false` once the request is finished.
You can use this to show and hide a loading animation in the frontend.
#### Factories
The `modelFactory` takes data, and returns a model. The result of all requests (with the exception
of the `delete` method) is run through this factory. The factory should return the appropriate model, see
[models](#models) down below on how to handle data in models.
`getAll()` checks if the response is an array, if that's the case, it will run each entry in it through
the `modelFactory`.
It is possible to define a different factory for each request. This is done by implementing a method called
`model{TYPE}Factory(data)` in your service. As a fallback if the specific factory is not defined,
`modelFactory` will be used.
#### Before Request
For each request exists a `before{TYPE}(model)` method. It recieves the model, can alter it and should return
the modified version.
This is useful to make unix timestamps from javascript dates, for example.
#### After Request ?
There is no `after{TYPE}` method which would be called after a request is done.
Processing raw api data should be done in the constructor of the model, see more on that below.
## Models
Models are a bit simpler than services.
They usually consist of a declaration of defaults and an optional constructor.
Models are located in `src/models`.
Each model should extend the `AbstractModel`.
This handles the default value parsing.
A model _does not_ handle any http requests, that's what services are for.
A simple model can look like this:
```javascript
import AbstractModel from './abstractModel'
import TaskModel from './task'
import UserModel from './user'
export default class ListModel extends AbstractModel {
constructor(data) {
// The constructor of AbstractModel handles all the default parsing.
super(data)
// Make all tasks to task models
this.tasks = this.tasks.map(t => {
return new TaskModel(t)
})
this.owner = new UserModel(this.owner)
}
// Default attributes that define the "empty" state.
defaults() {
return {
id: 0,
title: '',
description: '',
owner: UserModel,
tasks: [],
namespaceID: 0,
created: 0,
updated: 0,
}
}
}
```
#### Default values
The `defaults()` functions provides all default values.
The `AbstractModel` constructor will take all the data provided to it, and fill any non-existent,
`undefined` or `null` value with the default provided by the function.
#### Constructor
The `AbstractModel` constructor handles all the default value parsing.
In your model, the constructor can do additional parsing, like making js date object from unix timestamps
or parsing the contents of a child-array into a model.
If the model does nothing like this, you don't need to define a constructor at all.
The parent will handle it all.
#### Access to the data
After initializing a model, it is possible to access all properties via `model.property`.
To make sure the property actually exists, provide it as a default.

View file

@ -9,6 +9,7 @@
},
"dependencies": {
"bulma": "^0.7.1",
"lodash": "^4.17.11",
"v-tooltip": "^2.0.0-rc.33",
"vue": "^2.5.17"
},

View file

@ -70,7 +70,7 @@
</ul>
</div>
<aside class="menu namespaces-lists">
<div class="spinner" :class="{ 'is-loading': loading}"></div>
<div class="spinner" :class="{ 'is-loading': namespaceService.loading}"></div>
<template v-for="n in namespaces">
<div :key="n.id">
<router-link v-tooltip.right="'Settings'" :to="{name: 'editNamespace', params: {id: n.id} }" class="nsettings" v-if="n.id > 0">
@ -117,9 +117,9 @@
<script>
import auth from './auth'
import {HTTP} from './http-common'
import message from './message'
import router from './router'
import NamespaceService from './services/namespace'
export default {
name: 'app',
@ -127,7 +127,6 @@
data() {
return {
user: auth.user,
loading: false,
namespaces: [],
mobileMenuActive: false,
fullpage: false,
@ -165,15 +164,13 @@
return 'https://www.gravatar.com/avatar/' + this.user.infos.avatar + '?s=50'
},
loadNamespaces() {
const cancel = message.setLoading(this)
HTTP.get(`namespaces`, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
.then(response => {
this.$set(this, 'namespaces', response.data)
cancel()
let namespaceService = new NamespaceService()
namespaceService.getAll()
.then(r => {
this.$set(this, 'namespaces', r)
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
loadNamespacesIfNeeded(e){
@ -189,9 +186,6 @@
setFullPage() {
this.fullpage = true;
},
handleError(e) {
message.error(e, this)
}
},
}
</script>

View file

@ -11,13 +11,14 @@ export default {
},
login(context, creds, redirect) {
localStorage.removeItem('token') // Delete an eventually preexisting old token
HTTP.post('login', {
username: creds.username,
password: creds.password
})
.then(response => {
// Save the token to local storage for later use
localStorage.removeItem('token') // Delete an eventually preexisting old token
localStorage.setItem('token', response.data.token)
// Tell others the user is autheticated

View file

@ -1,5 +1,5 @@
<template>
<div class="loader-container" :class="{ 'is-loading': loading}">
<div class="loader-container" :class="{ 'is-loading': listService.loading}">
<div class="card">
<header class="card-header">
<p class="card-header-title">
@ -12,25 +12,25 @@
<div class="field">
<label class="label" for="listtext">List Name</label>
<div class="control">
<input v-focus :class="{ 'disabled': loading}" :disabled="loading" class="input" type="text" id="listtext" placeholder="The list title goes here..." v-model="list.title">
<input v-focus :class="{ 'disabled': listService.loading}" :disabled="listService.loading" class="input" type="text" id="listtext" placeholder="The list title goes here..." v-model="list.title">
</div>
</div>
<div class="field">
<label class="label" for="listdescription">Description</label>
<div class="control">
<textarea :class="{ 'disabled': loading}" :disabled="loading" class="textarea" placeholder="The lists description goes here..." id="listdescription" v-model="list.description"></textarea>
<textarea :class="{ 'disabled': listService.loading}" :disabled="listService.loading" class="textarea" placeholder="The lists description goes here..." id="listdescription" v-model="list.description"></textarea>
</div>
</div>
</form>
<div class="columns bigbuttons">
<div class="column">
<button @click="submit()" class="button is-primary is-fullwidth" :class="{ 'is-loading': loading}">
<button @click="submit()" class="button is-primary is-fullwidth" :class="{ 'is-loading': listService.loading}">
Save
</button>
</div>
<div class="column is-1">
<button @click="showDeleteModal = true" class="button is-danger is-fullwidth" :class="{ 'is-loading': loading}">
<button @click="showDeleteModal = true" class="button is-danger is-fullwidth" :class="{ 'is-loading': listService.loading}">
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
@ -41,9 +41,8 @@
</div>
</div>
<manageusers :id="list.id" type="list" :userIsAdmin="userIsAdmin" />
<manageteams :id="list.id" type="list" :userIsAdmin="userIsAdmin" />
<component :is="manageUsersComponent" :id="list.id" type="list" :userIsAdmin="userIsAdmin"></component>
<component :is="manageTeamsComponent" :id="list.id" type="list" :userIsAdmin="userIsAdmin"></component>
<modal
v-if="showDeleteModal"
@ -59,21 +58,25 @@
<script>
import auth from '../../auth'
import router from '../../router'
import {HTTP} from '../../http-common'
import message from '../../message'
import manageusers from '../sharing/user'
import manageteams from '../sharing/team'
import ListModel from '../../models/list'
import ListService from '../../services/list'
export default {
name: "EditList",
data() {
return {
list: {title: '', description:''},
error: '',
loading: false,
list: ListModel,
listService: ListService,
showDeleteModal: false,
user: auth.user,
userIsAdmin: false,
userIsAdmin: false, // FIXME: we should be able to know somehow if the user is admin, not only based on if he's the owner
manageUsersComponent: '',
manageTeamsComponent: '',
}
},
components: {
@ -85,10 +88,9 @@
if (!auth.user.authenticated) {
router.push({name: 'home'})
}
this.list.id = this.$route.params.id
},
created() {
this.listService = new ListService()
this.loadList()
},
watch: {
@ -97,61 +99,49 @@
},
methods: {
loadList() {
const cancel = message.setLoading(this)
HTTP.get(`lists/` + this.$route.params.id, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
.then(response => {
this.$set(this, 'list', response.data)
if (response.data.owner.id === this.user.infos.id) {
let list = new ListModel({id: this.$route.params.id})
this.listService.get(list)
.then(r => {
this.$set(this, 'list', r)
if (r.owner.id === this.user.infos.id) {
this.userIsAdmin = true
}
cancel()
// This will trigger the dynamic loading of components once we actually have all the data to pass to them
this.manageTeamsComponent = 'manageteams'
this.manageUsersComponent = 'manageusers'
})
.catch(e => {
this.handleError(e)
message.error(e, this)
})
},
submit() {
const cancel = message.setLoading(this)
HTTP.post(`lists/` + this.$route.params.id, this.list, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
.then(response => {
this.listService.update(this.list)
.then(r => {
// Update the list in the parent
for (const n in this.$parent.namespaces) {
let lists = this.$parent.namespaces[n].lists
for (const l in lists) {
if (lists[l].id === response.data.id) {
this.$set(this.$parent.namespaces[n].lists, l, response.data)
if (lists[l].id === r.id) {
this.$set(this.$parent.namespaces[n].lists, l, r)
}
}
}
this.handleSuccess({message: 'The list was successfully updated.'})
cancel()
message.success({message: 'The list was successfully updated.'}, this)
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
deleteList() {
const cancel = message.setLoading(this)
HTTP.delete(`lists/` + this.$route.params.id, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.listService.delete(this.list)
.then(() => {
this.handleSuccess({message: 'The list was successfully deleted.'})
cancel()
message.success({message: 'The list was successfully deleted.'}, this)
router.push({name: 'home'})
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
handleError(e) {
message.error(e, this)
},
handleSuccess(e) {
message.success(e, this)
}
}
}
</script>

View file

@ -7,8 +7,8 @@
<h3>Create a new list</h3>
<form @submit.prevent="newList" @keyup.esc="back()">
<div class="field is-grouped">
<p class="control is-expanded" :class="{ 'is-loading': loading}">
<input v-focus class="input" :class="{ 'disabled': loading}" v-model="list.title" type="text" placeholder="The list's name goes here...">
<p class="control is-expanded" :class="{ 'is-loading': listService.loading}">
<input v-focus class="input" :class="{ 'disabled': listService.loading}" v-model="list.title" type="text" placeholder="The list's name goes here...">
</p>
<p class="control">
<button type="submit" class="button is-success noshadow">
@ -26,16 +26,16 @@
<script>
import auth from '../../auth'
import router from '../../router'
import {HTTP} from '../../http-common'
import message from '../../message'
import ListService from '../../services/list'
import ListModel from '../../models/list'
export default {
name: "NewList",
data() {
return {
list: {title: ''},
error: '',
loading: false
list: ListModel,
listService: ListService,
}
},
beforeMount() {
@ -45,33 +45,26 @@
}
},
created() {
this.list = new ListModel()
this.listService = new ListService()
this.$parent.setFullPage();
},
methods: {
newList() {
const cancel = message.setLoading(this)
HTTP.put(`namespaces/` + this.$route.params.id + `/lists`, this.list, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.list.namespaceID = this.$route.params.id
this.listService.create(this.list)
.then(response => {
this.$parent.loadNamespaces()
this.handleSuccess({message: 'The list was successfully created.'})
cancel()
router.push({name: 'showList', params: {id: response.data.id}})
message.success({message: 'The list was successfully created.'}, this)
router.push({name: 'showList', params: {id: response.id}})
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
back() {
router.go(-1)
},
handleError(e) {
message.error(e, this)
},
handleSuccess(e) {
message.success(e, this)
}
}
}
</script>

View file

@ -1,5 +1,5 @@
<template>
<div class="loader-container" :class="{ 'is-loading': loading}">
<div class="loader-container" :class="{ 'is-loading': listService.loading}">
<div class="content">
<router-link :to="{ name: 'editList', params: { id: list.id } }" class="icon settings is-medium">
<icon icon="cog" size="2x"/>
@ -8,8 +8,8 @@
</div>
<form @submit.prevent="addTask()">
<div class="field is-grouped">
<p class="control has-icons-left is-expanded" :class="{ 'is-loading': loading}">
<input v-focus class="input" :class="{ 'disabled': loading}" v-model="newTask.text" type="text" placeholder="Add a new task...">
<p class="control has-icons-left is-expanded" :class="{ 'is-loading': taskService.loading}">
<input v-focus class="input" :class="{ 'disabled': taskService.loading}" v-model="newTask.text" type="text" placeholder="Add a new task...">
<span class="icon is-small is-left">
<icon icon="tasks"/>
</span>
@ -68,21 +68,21 @@
<div class="field">
<label class="label" for="tasktext">Task Text</label>
<div class="control">
<input v-focus :class="{ 'disabled': loading}" :disabled="loading" class="input" type="text" id="tasktext" placeholder="The task text is here..." v-model="taskEditTask.text">
<input v-focus :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="input" type="text" id="tasktext" placeholder="The task text is here..." v-model="taskEditTask.text">
</div>
</div>
<div class="field">
<label class="label" for="taskdescription">Description</label>
<div class="control">
<textarea :class="{ 'disabled': loading}" :disabled="loading" class="textarea" placeholder="The tasks description goes here..." id="taskdescription" v-model="taskEditTask.description"></textarea>
<textarea :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="textarea" placeholder="The tasks description goes here..." id="taskdescription" v-model="taskEditTask.description"></textarea>
</div>
</div>
<b>Reminder Dates</b>
<div class="reminder-input" :class="{ 'overdue': (r < nowUnix && index !== (taskEditTask.reminderDates.length - 1))}" v-for="(r, index) in taskEditTask.reminderDates" :key="index">
<flat-pickr
:class="{ 'disabled': loading}"
:disabled="loading"
:class="{ 'disabled': taskService.loading}"
:disabled="taskService.loading"
:v-model="taskEditTask.reminderDates"
:config="flatPickerConfig"
:id="'taskreminderdate' + index"
@ -97,9 +97,9 @@
<label class="label" for="taskduedate">Due Date</label>
<div class="control">
<flat-pickr
:class="{ 'disabled': loading}"
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="loading"
:disabled="taskService.loading"
v-model="taskEditTask.dueDate"
:config="flatPickerConfig"
id="taskduedate"
@ -113,9 +113,9 @@
<div class="control columns">
<div class="column">
<flat-pickr
:class="{ 'disabled': loading}"
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="loading"
:disabled="taskService.loading"
v-model="taskEditTask.startDate"
:config="flatPickerConfig"
id="taskduedate"
@ -124,9 +124,9 @@
</div>
<div class="column">
<flat-pickr
:class="{ 'disabled': loading}"
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="loading"
:disabled="taskService.loading"
v-model="taskEditTask.endDate"
:config="flatPickerConfig"
id="taskduedate"
@ -140,11 +140,11 @@
<label class="label" for="">Repeat after</label>
<div class="control repeat-after-input columns">
<div class="column">
<input class="input" placeholder="Specify an amount..." v-model="repeatAfter.amount"/>
<input class="input" placeholder="Specify an amount..." v-model="taskEditTask.repeatAfter.amount"/>
</div>
<div class="column is-3">
<div class="select">
<select v-model="repeatAfter.type">
<select v-model="taskEditTask.repeatAfter.type">
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="weeks">Weeks</option>
@ -179,14 +179,14 @@
</div>
<div class="field has-addons">
<div class="control is-expanded">
<input @keyup.enter="addSubtask()" :class="{ 'disabled': loading}" :disabled="loading" class="input" type="text" id="tasktext" placeholder="New subtask" v-model="newTask.text"/>
<input @keyup.enter="addSubtask()" :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="input" type="text" id="tasktext" placeholder="New subtask" v-model="newTask.text"/>
</div>
<div class="control">
<a class="button" @click="addSubtask()"><icon icon="plus"></icon></a>
</div>
</div>
<button type="submit" class="button is-success is-fullwidth" :class="{ 'is-loading': loading}">
<button type="submit" class="button is-success is-fullwidth" :class="{ 'is-loading': taskService.loading}">
Save
</button>
@ -202,26 +202,30 @@
<script>
import auth from '../../auth'
import router from '../../router'
import {HTTP} from '../../http-common'
import message from '../../message'
import flatPickr from 'vue-flatpickr-component';
import 'flatpickr/dist/flatpickr.css';
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import ListService from '../../services/list'
import TaskService from '../../services/task'
import TaskModel from '../../models/task'
import ListModel from '../../models/list'
export default {
data() {
return {
listID: this.$route.params.id,
listService: ListService,
taskService: TaskService,
list: {},
newTask: {text: ''},
error: '',
loading: false,
newTask: TaskModel,
isTaskEdit: false,
taskEditTask: {
subtasks: [],
},
lastReminder: 0,
nowUnix: new Date(),
repeatAfter: {type: 'days', amount: null},
flatPickerConfig:{
altFormat: 'j M Y H:i',
altInput: true,
@ -242,6 +246,9 @@
}
},
created() {
this.listService = new ListService()
this.taskService = new TaskService()
this.newTask = new TaskModel()
this.loadList()
},
watch: {
@ -251,243 +258,89 @@
methods: {
loadList() {
this.isTaskEdit = false
const cancel = message.setLoading(this)
HTTP.get(`lists/` + this.$route.params.id, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
.then(response => {
for (const t in response.data.tasks) {
response.data.tasks[t] = this.fixStuffComingFromAPI(response.data.tasks[t])
}
// This adds a new elemednt "list" to our object which contains all lists
response.data.tasks = this.sortTasks(response.data.tasks)
this.$set(this, 'list', response.data)
if (this.list.tasks === null) {
this.list.tasks = []
}
cancel() // cancel the timer
// We create an extra list object instead of creating it in this.list because that would trigger a ui update which would result in bad ux.
let list = new ListModel({id: this.$route.params.id})
this.listService.get(list)
.then(r => {
this.$set(this, 'list', r)
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
addTask() {
const cancel = message.setLoading(this)
HTTP.put(`lists/` + this.$route.params.id, this.newTask, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
.then(response => {
this.addTaskToList(response.data)
this.handleSuccess({message: 'The task was successfully created.'})
cancel() // cancel the timer
this.newTask.listID = this.$route.params.id
this.taskService.create(this.newTask)
.then(r => {
this.list.addTaskToList(r)
message.success({message: 'The task was successfully created.'}, this)
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
this.newTask = {}
},
addTaskToList(task) {
// If it's a subtask, add it to its parent, otherwise append it to the list of tasks
if (task.parentTaskID === 0) {
this.list.tasks.push(task)
} else {
for (const t in this.list.tasks) {
if (this.list.tasks[t].id === task.parentTaskID) {
this.list.tasks[t].subtasks.push(task)
break
}
}
}
// Update the current edit task if needed
if (task.ParentTask === this.taskEditTask.id) {
this.taskEditTask.subtasks.push(task)
}
this.list.tasks = this.sortTasks(this.list.tasks)
},
markAsDone(e) {
let context = this
if (e.target.checked) {
setTimeout(doTheDone, 300); // Delay it to show the animation when marking a task as done
} else {
doTheDone() // Don't delay it when un-marking it as it doesn't have an animation the other way around
}
function doTheDone() {
const cancel = message.setLoading(context)
HTTP.post(`tasks/` + e.target.id, {done: e.target.checked}, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
.then(response => {
context.updateTaskByID(parseInt(e.target.id), response.data)
context.handleSuccess({message: 'The task was successfully ' + (e.target.checked ? '' : 'un-') + 'marked as done.'})
cancel() // To not set the spinner to loading when the request is made in less than 100ms, would lead to loading infinitly.
let updateFunc = () => {
// We get the task, update the 'done' property and then push it to the api.
let task = this.list.getTaskByID(e.target.id)
task.done = e.target.checked
this.taskService.update(task)
.then(r => {
this.updateTaskInList(r)
message.success({message: 'The task was successfully ' + (task.done ? '' : 'un-') + 'marked as done.'}, this)
})
.catch(e => {
cancel()
context.handleError(e)
message.error(e, this)
})
}
if (e.target.checked) {
setTimeout(updateFunc(), 300); // Delay it to show the animation when marking a task as done
} else {
updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
}
},
editTask(id) {
// Find the selected task and set it to the current object
for (const t in this.list.tasks) {
if (this.list.tasks[t].id === id) {
this.taskEditTask = this.list.tasks[t]
break
}
}
if (this.taskEditTask.reminderDates === null) {
this.taskEditTask.reminderDates = []
}
this.taskEditTask.reminderDates = this.removeNullsFromArray(this.taskEditTask.reminderDates)
this.taskEditTask.reminderDates.push(null)
// Re-convert the the amount from seconds to be used with our form
let repeatAfterHours = (this.taskEditTask.repeatAfter / 60) / 60
// if its dividable by 24, its something with days
if (repeatAfterHours % 24 === 0) {
let repeatAfterDays = repeatAfterHours / 24
if (repeatAfterDays % 7 === 0) {
this.repeatAfter.type = 'weeks'
this.repeatAfter.amount = repeatAfterDays / 7
} else if (repeatAfterDays % 30 === 0) {
this.repeatAfter.type = 'months'
this.repeatAfter.amount = repeatAfterDays / 30
} else if (repeatAfterDays % 365 === 0) {
this.repeatAfter.type = 'years'
this.repeatAfter.amount = repeatAfterDays / 365
} else {
this.repeatAfter.type = 'days'
this.repeatAfter.amount = repeatAfterDays
}
} else {
// otherwise hours
this.repeatAfter.type = 'hours'
this.repeatAfter.amount = repeatAfterHours
}
if(this.taskEditTask.subtasks === null) {
this.taskEditTask.subtasks = [];
}
let theTask = this.list.getTaskByID(id) // Somehow this does not work if we directly assign this to this.taskEditTask
this.taskEditTask = theTask
this.isTaskEdit = true
},
editTaskSubmit() {
const cancel = message.setLoading(this)
// Convert the date in a unix timestamp
this.taskEditTask.dueDate = (+ new Date(this.taskEditTask.dueDate)) / 1000
this.taskEditTask.startDate = (+ new Date(this.taskEditTask.startDate)) / 1000
this.taskEditTask.endDate = (+ new Date(this.taskEditTask.endDate)) / 1000
// remove all nulls
this.taskEditTask.reminderDates = this.removeNullsFromArray(this.taskEditTask.reminderDates)
// Make normal timestamps from js timestamps
for (const t in this.taskEditTask.reminderDates) {
this.taskEditTask.reminderDates[t] = Math.round(this.taskEditTask.reminderDates[t] / 1000)
}
// Make the repeating amount to seconds
let repeatAfterSeconds = 0
if (this.repeatAfter.amount !== null || this.repeatAfter.amount !== 0) {
switch (this.repeatAfter.type) {
case 'hours':
repeatAfterSeconds = this.repeatAfter.amount * 60 * 60
break;
case 'days':
repeatAfterSeconds = this.repeatAfter.amount * 60 * 60 * 24
break;
case 'weeks':
repeatAfterSeconds = this.repeatAfter.amount * 60 * 60 * 24 * 7
break;
case 'months':
repeatAfterSeconds = this.repeatAfter.amount * 60 * 60 * 24 * 30
break;
case 'years':
repeatAfterSeconds = this.repeatAfter.amount * 60 * 60 * 24 * 365
break;
}
}
this.taskEditTask.repeatAfter = repeatAfterSeconds
HTTP.post(`tasks/` + this.taskEditTask.id, this.taskEditTask, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
.then(response => {
// Update the task in the list
this.updateTaskByID(this.taskEditTask.id, response.data)
// Also update the current taskedit object so the ui changes
response.data.reminderDates.push(null) // To be able to add a new one
this.$set(this, 'taskEditTask', response.data)
this.handleSuccess({message: 'The task was successfully updated.'})
cancel() // cancel the timers
this.taskService.update(this.taskEditTask)
.then(r => {
this.updateTaskInList(r)
this.$set(this, 'taskEditTask', r)
message.success({message: 'The task was successfully updated.'}, this)
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
addSubtask() {
this.newTask.parentTaskID = this.taskEditTask.id
this.addTask()
},
updateTaskByID(id, updatedTask) {
updateTaskInList(task) {
// We need to do the update here in the component, because there is no way of notifiying vue of the change otherwise.
for (const t in this.list.tasks) {
if (this.list.tasks[t].id === id) {
this.$set(this.list.tasks, t, this.fixStuffComingFromAPI(updatedTask))
if (this.list.tasks[t].id === task.id) {
this.$set(this.list.tasks, t, task)
break
}
if (this.list.tasks[t].id === updatedTask.parentTaskID) {
if (this.list.tasks[t].id === task.parentTaskID) {
for (const s in this.list.tasks[t].subtasks) {
if (this.list.tasks[t].subtasks[s].id === updatedTask.id) {
this.$set(this.list.tasks[t].subtasks, s, updatedTask)
if (this.list.tasks[t].subtasks[s].id === task.id) {
this.$set(this.list.tasks[t].subtasks, s, task)
break
}
}
}
}
this.list.tasks = this.sortTasks(this.list.tasks)
},
fixStuffComingFromAPI(task) {
// Make date objects from timestamps
task.dueDate = this.parseDateIfNessecary(task.dueDate)
task.startDate = this.parseDateIfNessecary(task.startDate)
task.endDate = this.parseDateIfNessecary(task.endDate)
for (const rd in task.reminderDates) {
task.reminderDates[rd] = this.parseDateIfNessecary(task.reminderDates[rd])
}
// Make subtasks into empty array if null
if (task.subtasks === null) {
task.subtasks = []
}
return task
},
sortTasks(tasks) {
if (tasks === null) {
return tasks
}
return tasks.sort(function(a,b) {
if (a.done < b.done)
return -1
if (a.done > b.done)
return 1
return 0
})
},
parseDateIfNessecary(dateUnix) {
let dateobj = (+new Date(dateUnix * 1000))
if (dateobj === 0 || dateUnix === 0) {
dateUnix = null
} else {
dateUnix = dateobj
}
return dateUnix
this.list.sortTasks()
},
updateLastReminderDate(selectedDates) {
this.lastReminder = +new Date(selectedDates[0])
@ -513,23 +366,6 @@
this.taskEditTask.reminderDates.splice(index, 1)
// Reset the last to 0 to have the "add reminder" button
this.taskEditTask.reminderDates[this.taskEditTask.reminderDates.length - 1] = null
},
removeNullsFromArray(array) {
for (const index in array) {
if (array[index] === null) {
array.splice(index, 1)
}
}
return array
},
formatUnixDate(dateUnix) {
return (new Date(dateUnix)).toLocaleString()
},
handleError(e) {
message.error(e, this)
},
handleSuccess(e) {
message.success(e, this)
}
}
}

View file

@ -32,6 +32,7 @@
import router from '../../router'
import {HTTP} from '../../http-common'
import message from '../../message'
import TaskService from '../../services/task'
export default {
name: "ShowTasks",
@ -39,7 +40,8 @@
return {
loading: true,
tasks: [],
hasUndoneTasks: false
hasUndoneTasks: false,
taskService: TaskService,
}
},
props: {
@ -48,10 +50,22 @@
showAll: Boolean,
},
created() {
this.taskService = new TaskService()
this.loadPendingTasks()
},
methods: {
loadPendingTasks() {
// We can't really make this code work until 0.6 is released which will make this exact thing a lot easier.
// Because the feature we need here (specifying sort order and start/end date via query parameters) is already in master, we'll just wait and use the legacy method until then.
/*
let taskDummy = new TaskModel() // Used to specify options for the request
this.taskService.getAll(taskDummy)
.then(r => {
this.tasks = r
})
.catch(e => {
message.error(e, this)
})*/
const cancel = message.setLoading(this)
let url = `tasks/all/duedate`

View file

@ -1,5 +1,5 @@
<template>
<div class="loader-container" v-bind:class="{ 'is-loading': loading}">
<div class="loader-container" v-bind:class="{ 'is-loading': namespaceService.loading}">
<div class="card">
<header class="card-header">
<p class="card-header-title">
@ -12,25 +12,25 @@
<div class="field">
<label class="label" for="namespacetext">Namespace Name</label>
<div class="control">
<input v-focus :class="{ 'disabled': loading}" :disabled="loading" class="input" type="text" id="namespacetext" placeholder="The namespace text is here..." v-model="namespace.name">
<input v-focus :class="{ 'disabled': namespaceService.loading}" :disabled="namespaceService.loading" class="input" type="text" id="namespacetext" placeholder="The namespace text is here..." v-model="namespace.name">
</div>
</div>
<div class="field">
<label class="label" for="namespacedescription">Description</label>
<div class="control">
<textarea :class="{ 'disabled': loading}" :disabled="loading" class="textarea" placeholder="The namespaces description goes here..." id="namespacedescription" v-model="namespace.description"></textarea>
<textarea :class="{ 'disabled': namespaceService.loading}" :disabled="namespaceService.loading" class="textarea" placeholder="The namespaces description goes here..." id="namespacedescription" v-model="namespace.description"></textarea>
</div>
</div>
</form>
<div class="columns bigbuttons">
<div class="column">
<button @click="submit()" class="button is-primary is-fullwidth" :class="{ 'is-loading': loading}">
<button @click="submit()" class="button is-primary is-fullwidth" :class="{ 'is-loading': namespaceService.loading}">
Save
</button>
</div>
<div class="column is-1">
<button @click="showDeleteModal = true" class="button is-danger is-fullwidth" :class="{ 'is-loading': loading}">
<button @click="showDeleteModal = true" class="button is-danger is-fullwidth" :class="{ 'is-loading': namespaceService.loading}">
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
@ -41,9 +41,8 @@
</div>
</div>
<manageusers :id="namespace.id" type="namespace" :userIsAdmin="userIsAdmin" />
<manageteams :id="namespace.id" type="namespace" :userIsAdmin="userIsAdmin" />
<component :is="manageUsersComponent" :id="namespace.id" type="namespace" :userIsAdmin="userIsAdmin"></component>
<component :is="manageTeamsComponent" :id="namespace.id" type="namespace" :userIsAdmin="userIsAdmin"></component>
<modal
v-if="showDeleteModal"
@ -59,21 +58,24 @@
<script>
import auth from '../../auth'
import router from '../../router'
import {HTTP} from '../../http-common'
import message from '../../message'
import manageusers from '../sharing/user'
import manageteams from '../sharing/team'
import NamespaceService from '../../services/namespace'
import NamespaceModel from '../../models/namespace'
export default {
name: "EditNamespace",
data() {
return {
namespace: {title: '', description:''},
error: '',
loading: false,
namespaceService: NamespaceService,
userIsAdmin: false,
manageUsersComponent: '',
manageTeamsComponent: '',
namespace: NamespaceModel,
showDeleteModal: false,
user: auth.user,
userIsAdmin: false,
}
},
components: {
@ -89,6 +91,7 @@
this.namespace.id = this.$route.params.id
},
created() {
this.namespaceService = new NamespaceService()
this.loadNamespace()
},
watch: {
@ -97,66 +100,47 @@
},
methods: {
loadNamespace() {
const cancel = message.setLoading(this)
HTTP.get(`namespaces/` + this.$route.params.id, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
.then(response => {
this.$set(this, 'namespace', response.data)
if (response.data.owner.id === this.user.infos.id) {
let namespace = new NamespaceModel({id: this.$route.params.id})
this.namespaceService.get(namespace)
.then(r => {
this.$set(this, 'namespace', r)
if (r.owner.id === this.user.infos.id) {
this.userIsAdmin = true
}
cancel()
// This will trigger the dynamic loading of components once we actually have all the data to pass to them
this.manageTeamsComponent = 'manageteams'
this.manageUsersComponent = 'manageusers'
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
submit() {
const cancel = message.setLoading(this)
HTTP.post(`namespaces/` + this.$route.params.id, this.namespace, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
.then(response => {
this.namespaceService.update(this.namespace)
.then(r => {
// Update the namespace in the parent
for (const n in this.$parent.namespaces) {
if (this.$parent.namespaces[n].id === response.data.id) {
response.data.lists = this.$parent.namespaces[n].lists
this.$set(this.$parent.namespaces, n, response.data)
if (this.$parent.namespaces[n].id === r.id) {
r.lists = this.$parent.namespaces[n].lists
this.$set(this.$parent.namespaces, n, r)
}
}
this.handleSuccess({message: 'The namespace was successfully updated.'})
cancel()
message.success({message: 'The namespace was successfully updated.'}, this)
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
deleteNamespace() {
const cancel = message.setLoading(this)
HTTP.delete(`namespaces/` + this.$route.params.id, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.namespaceService.delete(this.namespace)
.then(() => {
this.handleSuccess({message: 'The namespace was successfully deleted.'})
cancel()
message.success({message: 'The namespace was successfully deleted.'}, this)
router.push({name: 'home'})
})
.catch(e => {
cancel()
this.handleError(e)
})
},
handleError(e) {
message.error(e, this)
},
handleSuccess(e) {
message.success(e, this)
})
}
}
}
</script>
<style scoped>
.bigbuttons{
margin-top: 0.5rem;
}
</style>

View file

@ -7,8 +7,8 @@
<h3>Create a new namespace</h3>
<form @submit.prevent="newNamespace" @keyup.esc="back()">
<div class="field is-grouped">
<p class="control is-expanded" v-bind:class="{ 'is-loading': loading}">
<input v-focus class="input" v-bind:class="{ 'disabled': loading}" v-model="namespace.name" type="text" placeholder="The namespace's name goes here...">
<p class="control is-expanded" v-bind:class="{ 'is-loading': namespaceService.loading}">
<input v-focus class="input" v-bind:class="{ 'disabled': namespaceService.loading}" v-model="namespace.name" type="text" placeholder="The namespace's name goes here...">
</p>
<p class="control">
<button type="submit" class="button is-success noshadow">
@ -27,16 +27,16 @@
<script>
import auth from '../../auth'
import router from '../../router'
import {HTTP} from '../../http-common'
import message from '../../message'
import NamespaceModel from "../../models/namespace";
import NamespaceService from "../../services/namespace";
export default {
name: "NewNamespace",
data() {
return {
namespace: {title: ''},
error: '',
loading: false
namespace: NamespaceModel,
namespaceService: NamespaceService,
}
},
beforeMount() {
@ -46,32 +46,24 @@
}
},
created() {
this.namespace = new NamespaceModel()
this.namespaceService = new NamespaceService()
this.$parent.setFullPage();
},
methods: {
newNamespace() {
const cancel = message.setLoading(this)
HTTP.put(`namespaces`, this.namespace, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.namespaceService.create(this.namespace)
.then(() => {
this.$parent.loadNamespaces()
this.handleSuccess({message: 'The namespace was successfully created.'})
cancel()
message.success({message: 'The namespace was successfully created.'}, this)
router.push({name: 'home'})
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
back() {
router.go(-1)
},
handleError(e) {
message.error(e, this)
},
handleSuccess(e) {
message.success(e, this)
}
}
}

View file

@ -9,8 +9,8 @@
<div class="card-content content teams-list">
<form @submit.prevent="addTeam()" class="add-team-form" v-if="userIsAdmin">
<div class="field is-grouped">
<p class="control has-icons-left is-expanded" v-bind:class="{ 'is-loading': loading}">
<input class="input" v-bind:class="{ 'disabled': loading}" v-model.number="newTeam.team_id" type="text" placeholder="Add a new team...">
<p class="control has-icons-left is-expanded" v-bind:class="{ 'is-loading': this.teamService.loading}">
<input class="input" v-bind:class="{ 'disabled': this.teamService.loading}" v-model.number="teamStuffModel.teamID" type="text" placeholder="Add a new team...">
<span class="icon is-small is-left">
<icon icon="users"/>
</span>
@ -86,9 +86,12 @@
</template>
<script>
import {HTTP} from '../../http-common'
import auth from '../../auth'
import message from '../../message'
import TeamNamespaceService from '../../services/teamNamespace'
import TeamNamespaceModel from '../../models/teamNamespace'
import TeamListModel from '../../models/teamList'
import TeamListService from '../../services/teamList'
export default {
name: 'team',
@ -99,11 +102,13 @@
},
data() {
return {
loading: false,
teamService: Object, // This team service is either a teamNamespaceService or a teamListService, depending on the type we are using
teamStuffModel: Object,
currentUser: auth.user.infos,
typeString: '',
listTeams: [],
newTeam: {team_id: 0},
newTeam: {teamID: 0},
showTeamDeleteModal: false,
teamToDelete: 0,
}
@ -111,8 +116,12 @@
created() {
if (this.type === 'list') {
this.typeString = `list`
this.teamService = new TeamListService()
this.teamStuffModel = new TeamListModel({listID: this.id})
} else if (this.type === 'namespace') {
this.typeString = `namespace`
this.teamService = new TeamNamespaceService()
this.teamStuffModel = new TeamNamespaceModel({namespaceID: this.id})
} else {
throw new Error('Unknown type: ' + this.type)
}
@ -121,75 +130,61 @@
},
methods: {
loadTeams() {
const cancel = message.setLoading(this)
HTTP.get(this.typeString + `s/` + this.id + `/teams`, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
.then(response => {
this.$set(this, 'listTeams', response.data)
cancel()
this.teamService.getAll(this.teamStuffModel)
.then(r => {
this.$set(this, 'listTeams', r)
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
deleteTeam() {
const cancel = message.setLoading(this)
HTTP.delete(this.typeString + `s/` + this.id + `/teams/` + this.teamToDelete, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.teamService.delete(this.teamStuffModel)
.then(() => {
this.showTeamDeleteModal = false;
this.handleSuccess({message: 'The team was successfully deleted from the ' + this.typeString + '.'})
message.success({message: 'The team was successfully deleted from the ' + this.typeString + '.'}, this)
// FIXME: this should remove the team from the list instead of loading it again
this.loadTeams()
cancel()
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
addTeam(admin) {
const cancel = message.setLoading(this)
if(admin === null) {
admin = false
}
this.newTeam.right = 0
this.teamStuffModel.right = 0
if (admin) {
this.newTeam.right = 2
this.teamStuffModel.right = 2
}
HTTP.put(this.typeString + `s/` + this.id + `/teams`, this.newTeam, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.teamService.create(this.teamStuffModel)
.then(() => {
// FIXME: this should add the team to the list instead of loading it again
this.loadTeams()
this.handleSuccess({message: 'The team was successfully added.'})
cancel()
message.success({message: 'The team was successfully added.'}, this)
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
toggleTeamType(teamid, current) {
const cancel = message.setLoading(this)
let right = 0
this.teamStuffModel.teamID = teamid
this.teamStuffModel.right = 0
if (!current) {
right = 2
this.teamStuffModel.right = 2
}
HTTP.post(this.typeString + `s/` + this.id + `/teams/` + teamid, {right: right}, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.teamService.update(this.teamStuffModel)
.then(() => {
// FIXME: this should update the team in the list instead of loading it again
this.loadTeams()
this.handleSuccess({message: 'The team right was successfully updated.'})
cancel()
message.success({message: 'The team right was successfully updated.'}, this)
})
.catch(e => {
cancel()
this.handleError(e)
})
},
handleError(e) {
message.error(e, this)
},
handleSuccess(e) {
message.success(e, this)
})
}
},
}

View file

@ -9,21 +9,20 @@
<div class="card-content content users-list">
<form @submit.prevent="addUser()" class="add-user-form" v-if="userIsAdmin">
<div class="field is-grouped">
<p class="control is-expanded" v-bind:class="{ 'is-loading': loading}">
<p class="control is-expanded" v-bind:class="{ 'is-loading': userStuffService.loading}">
<multiselect
v-model="newUser"
v-model="user"
:options="foundUsers"
:multiple="false"
:searchable="true"
:loading="loading"
:loading="userService.loading"
:internal-search="true"
@search-change="findUsers"
placeholder="Type to search a user"
label="username"
track-by="user_id">
track-by="id">
<template slot="clear" slot-scope="props">
<div class="multiselect__clear" v-if="newUser.id !== 0" @mousedown.prevent.stop="clearAll(props.search)"></div>
<div class="multiselect__clear" v-if="user.id !== 0" @mousedown.prevent.stop="clearAll(props.search)"></div>
</template>
<span slot="noResult">Oops! No users found. Consider changing the search query.</span>
</multiselect>
@ -77,7 +76,7 @@
Admin
</template>
</button>
<button @click="userToDelete = u.id; showUserDeleteModal = true" class="button is-danger" v-if="u.id !== currentUser.id">
<button @click="user = u; showUserDeleteModal = true" class="button is-danger" v-if="u.id !== currentUser.id">
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
@ -100,12 +99,18 @@
</template>
<script>
import {HTTP} from '../../http-common'
import auth from '../../auth'
import message from '../../message'
import multiselect from 'vue-multiselect'
import 'vue-multiselect/dist/vue-multiselect.min.css'
import UserService from '../../services/user'
import UserNamespaceModel from '../../models/userNamespace'
import UserListModel from '../../models/userList'
import UserListService from '../../services/userList'
import UserNamespaceService from '../../services/userNamespace'
import UserModel from '../../models/user'
export default {
name: 'user',
props: {
@ -115,14 +120,15 @@
},
data() {
return {
loading: false,
userService: UserService, // To search for users
user: UserModel,
userStuff: Object, // This will be either UserNamespaceModel or UserListModel
userStuffService: Object, // This will be either UserListService or UserNamespaceService
currentUser: auth.user.infos,
typeString: '',
showUserDeleteModal: false,
users: [],
newUser: {username: '', user_id: 0},
userToDelete: 0,
newUserid: 0,
foundUsers: [],
}
},
@ -130,10 +136,17 @@
multiselect
},
created() {
this.userService = new UserService()
this.user = new UserModel()
if (this.type === 'list') {
this.typeString = `list`
this.userStuffService = new UserListService()
this.userStuff = new UserListModel({listID: this.id})
} else if (this.type === 'namespace') {
this.typeString = `namespace`
this.userStuffService = new UserNamespaceService()
this.userStuff = new UserNamespaceModel({namespaceID: this.id})
} else {
throw new Error('Unknown type: ' + this.type)
}
@ -142,100 +155,76 @@
},
methods: {
loadUsers() {
const cancel = message.setLoading(this)
HTTP.get(this.typeString + `s/` + this.id + `/users`, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.userStuffService.getAll(this.userStuff)
.then(response => {
//response.data.push(this.list.owner)
this.$set(this, 'users', response.data)
cancel()
this.$set(this, 'users', response)
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
deleteUser() {
const cancel = message.setLoading(this)
HTTP.delete(this.typeString + `s/` + this.id + `/users/` + this.userToDelete, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
// The api wants the user id as userID
let usr = this.user
this.userStuff.userID = usr.id
this.userStuffService.delete(this.userStuff)
.then(() => {
this.showUserDeleteModal = false;
this.handleSuccess({message: 'The user was successfully deleted from the ' + this.typeString + '.'})
message.success({message: 'The user was successfully deleted from the ' + this.typeString + '.'}, this)
this.loadUsers()
cancel()
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
addUser(admin) {
const cancel = message.setLoading(this)
if(admin === null) {
admin = false
}
this.newUser.right = 0
addUser(admin = false) {
this.userStuff.right = 0
if (admin) {
this.newUser.right = 2
this.userStuff.right = 2
}
// The api wants the user id as userID
this.userStuff.userID = this.user.id
this.$set(this, 'foundUsers', [])
HTTP.put(this.typeString + `s/` + this.id + `/users`, this.newUser, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.userStuffService.create(this.userStuff)
.then(() => {
this.loadUsers()
this.newUser = {}
this.handleSuccess({message: 'The user was successfully added.'})
cancel()
message.success({message: 'The user was successfully added.'}, this)
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
toggleUserType(userid, current) {
const cancel = message.setLoading(this)
let right = 0
this.userStuff.userID = userid
this.userStuff.right = 0
if (!current) {
right = 2
this.userStuff.right = 2
}
HTTP.post(this.typeString + `s/` + this.id + `/users/` + userid, {right: right}, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.userStuffService.update(this.userStuff)
.then(() => {
this.loadUsers()
this.handleSuccess({message: 'The user right was successfully updated.'})
cancel()
message.success({message: 'The user right was successfully updated.'}, this)
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
findUsers(query) {
const cancel = message.setLoading(this)
if(query === '') {
this.$set(this, 'foundUsers', [])
cancel()
return
}
this.$set(this, 'newUser', {username: '', user_id: 0})
HTTP.get(`users?s=` + query, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.userService.getAll({}, {s: query})
.then(response => {
this.$set(this, 'foundUsers', [])
for (const u in response.data) {
this.foundUsers.push({
username: response.data[u].username,
user_id: response.data[u].id,
})
}
cancel()
this.$set(this, 'foundUsers', response)
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
clearAll () {
@ -244,12 +233,6 @@
limitText (count) {
return `and ${count} others`
},
handleError(e) {
message.error(e, this)
},
handleSuccess(e) {
message.success(e, this)
}
},
}
</script>

View file

@ -1,5 +1,5 @@
<template>
<div class="loader-container" v-bind:class="{ 'is-loading': loading}">
<div class="loader-container" v-bind:class="{ 'is-loading': teamService.loading}">
<div class="card" v-if="userIsAdmin">
<header class="card-header">
<p class="card-header-title">
@ -12,25 +12,25 @@
<div class="field">
<label class="label" for="teamtext">Team Name</label>
<div class="control">
<input v-focus :class="{ 'disabled': loading}" :disabled="loading" class="input" type="text" id="teamtext" placeholder="The team text is here..." v-model="team.name">
<input v-focus :class="{ 'disabled': teamMemberService.loading}" :disabled="teamMemberService.loading" class="input" type="text" id="teamtext" placeholder="The team text is here..." v-model="team.name">
</div>
</div>
<div class="field">
<label class="label" for="teamdescription">Description</label>
<div class="control">
<textarea :class="{ 'disabled': loading}" :disabled="loading" class="textarea" placeholder="The teams description goes here..." id="teamdescription" v-model="team.description"></textarea>
<textarea :class="{ 'disabled': teamService.loading}" :disabled="teamService.loading" class="textarea" placeholder="The teams description goes here..." id="teamdescription" v-model="team.description"></textarea>
</div>
</div>
</form>
<div class="columns bigbuttons">
<div class="column">
<button @click="submit()" class="button is-success is-fullwidth" :class="{ 'is-loading': loading}">
<button @click="submit()" class="button is-success is-fullwidth" :class="{ 'is-loading': teamService.loading}">
Save
</button>
</div>
<div class="column is-1">
<button @click="showDeleteModal = true" class="button is-danger is-fullwidth" :class="{ 'is-loading': loading}">
<button @click="showDeleteModal = true" class="button is-danger is-fullwidth" :class="{ 'is-loading': teamService.loading}">
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
@ -50,8 +50,8 @@
<div class="card-content content team-members">
<form @submit.prevent="addUser()" class="add-member-form" v-if="userIsAdmin">
<div class="field is-grouped">
<p class="control has-icons-left is-expanded" v-bind:class="{ 'is-loading': loading}">
<input class="input" v-bind:class="{ 'disabled': loading}" v-model.number="newUser.id" type="text" placeholder="Add a new user...">
<p class="control has-icons-left is-expanded" v-bind:class="{ 'is-loading': teamMemberService.loading}">
<input class="input" v-bind:class="{ 'disabled': teamMemberService.loading}" v-model.number="member.id" type="text" placeholder="Add a new user...">
<span class="icon is-small is-left">
<icon icon="user"/>
</span>
@ -90,7 +90,7 @@
</template>
</td>
<td class="actions" v-if="userIsAdmin">
<button @click="toggleUserType(m.id, m.admin)" class="button buttonright is-primary" v-if="m.id !== user.infos.id">
<button @click="toggleUserType(m)" class="button buttonright is-primary" v-if="m.id !== user.infos.id">
Make
<template v-if="!m.admin">
Admin
@ -99,7 +99,7 @@
Member
</template>
</button>
<button @click="userToDelete = m.id; showUserDeleteModal = true" class="button is-danger" v-if="m.id !== user.infos.id">
<button @click="member = m; showUserDeleteModal = true" class="button is-danger" v-if="m.id !== user.infos.id">
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
@ -137,22 +137,26 @@
<script>
import auth from '../../auth'
import router from '../../router'
import {HTTP} from '../../http-common'
import message from '../../message'
import TeamService from '../../services/team'
import TeamModel from '../../models/team'
import TeamMemberService from '../../services/teamMember'
import TeamMemberModel from '../../models/teamMember'
export default {
name: "EditTeam",
data() {
return {
team: {title: '', description:''},
error: '',
loading: false,
teamService: TeamService,
teamMemberService: TeamMemberService,
team: TeamModel,
member: TeamMemberModel,
showDeleteModal: false,
showUserDeleteModal: false,
user: auth.user,
userIsAdmin: false,
userToDelete: 0,
newUser: {id:0},
}
},
beforeMount() {
@ -162,6 +166,8 @@
}
},
created() {
this.teamService = new TeamService()
this.teamMemberService = new TeamMemberService()
this.loadTeam()
},
watch: {
@ -170,97 +176,71 @@
},
methods: {
loadTeam() {
const cancel = message.setLoading(this)
HTTP.get(`teams/` + this.$route.params.id, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.member = new TeamMemberModel({teamID: this.$route.params.id})
this.team = new TeamModel({id: this.$route.params.id})
this.teamService.get(this.team)
.then(response => {
this.$set(this, 'team', response.data)
let members = response.data.members
this.$set(this, 'team', response)
let members = response.members
for (const m in members) {
members[m].teamID = this.$route.params.id
if (members[m].id === this.user.infos.id && members[m].admin) {
this.userIsAdmin = true
}
}
cancel()
})
.catch(e => {
this.handleError(e)
message.error(e, this)
})
},
submit() {
const cancel = message.setLoading(this)
HTTP.post(`teams/` + this.$route.params.id, this.team, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.teamService.update(this.team)
.then(response => {
// Update the team in the parent
for (const n in this.$parent.teams) {
if (this.$parent.teams[n].id === response.data.id) {
response.data.lists = this.$parent.teams[n].lists
this.$set(this.$parent.teams, n, response.data)
}
}
this.handleSuccess({message: 'The team was successfully updated.'})
cancel()
this.team = response
message.success({message: 'The team was successfully updated.'}, this)
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
deleteTeam() {
const cancel = message.setLoading(this)
HTTP.delete(`teams/` + this.$route.params.id, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.teamService.delete(this.team)
.then(() => {
this.handleSuccess({message: 'The team was successfully deleted.'})
cancel()
router.push({name: 'home'})
message.success({message: 'The team was successfully deleted.'}, this)
router.push({name: 'listTeams'})
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
deleteUser() {
const cancel = message.setLoading(this)
HTTP.delete(`teams/` + this.$route.params.id + `/members/` + this.userToDelete, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.teamMemberService.delete(this.member)
.then(() => {
this.showUserDeleteModal = false;
this.handleSuccess({message: 'The user was successfully deleted from the team.'})
message.success({message: 'The user was successfully deleted from the team.'}, this)
this.loadTeam()
cancel()
})
.catch(e => {
cancel()
this.handleError(e)
})
},
addUser(admin) {
const cancel = message.setLoading(this)
if(admin === null) {
admin = false
}
HTTP.put(`teams/` + this.$route.params.id + `/members`, {admin: admin, user_id: this.newUser.id}, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
.then(() => {
this.loadTeam()
this.handleSuccess({message: 'The team member was successfully added.'})
cancel()
})
.catch(e => {
cancel()
this.handleError(e)
})
},
toggleUserType(userid, current) {
this.userToDelete = userid
this.newUser.id = userid
this.deleteUser()
this.addUser(!current)
},
handleError(e) {
message.error(e, this)
})
.finally(() => {
this.showUserDeleteModal = false
})
},
handleSuccess(e) {
message.success(e, this)
addUser() {
this.teamMemberService.create(this.member)
.then(() => {
this.loadTeam()
message.success({message: 'The team member was successfully added.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
toggleUserType(member) {
this.member = member
this.member.admin = !member.admin
this.deleteUser()
this.addUser()
}
}
}

View file

@ -1,5 +1,5 @@
<template>
<div class="content loader-container" v-bind:class="{ 'is-loading': loading}">
<div class="content loader-container" v-bind:class="{ 'is-loading': teamService.loading}">
<router-link :to="{name:'newTeam'}" class="button is-success button-right" >
<span class="icon is-small">
<icon icon="plus"/>
@ -20,16 +20,15 @@
<script>
import auth from '../../auth'
import router from '../../router'
import {HTTP} from '../../http-common'
import message from '../../message'
import TeamService from '../../services/team'
export default {
name: "ListTeams",
data() {
return {
teamService: TeamService,
teams: [],
error: '',
loading: false,
}
},
beforeMount() {
@ -39,23 +38,18 @@
}
},
created() {
this.teamService = new TeamService()
this.loadTeams()
},
methods: {
loadTeams() {
const cancel = message.setLoading(this)
HTTP.get(`teams`, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.teamService.getAll()
.then(response => {
this.$set(this, 'teams', response.data)
cancel()
this.$set(this, 'teams', response)
})
.catch(e => {
this.handleError(e)
})
},
handleError(e) {
message.error(e, this)
})
},
}
}

View file

@ -7,8 +7,8 @@
<h3>Create a new team</h3>
<form @submit.prevent="newTeam" @keyup.esc="back()">
<div class="field is-grouped">
<p class="control is-expanded" v-bind:class="{ 'is-loading': loading}">
<input v-focus class="input" v-bind:class="{ 'disabled': loading}" v-model="team.name" type="text" placeholder="The team's name goes here...">
<p class="control is-expanded" v-bind:class="{ 'is-loading': teamService.loading}">
<input v-focus class="input" v-bind:class="{ 'disabled': teamService.loading}" v-model="team.name" type="text" placeholder="The team's name goes here...">
</p>
<p class="control">
<button type="submit" class="button is-success noshadow">
@ -26,16 +26,16 @@
<script>
import auth from '../../auth'
import router from '../../router'
import {HTTP} from '../../http-common'
import message from '../../message'
import TeamModel from '../../models/team'
import TeamService from '../../services/team'
export default {
name: "NewTeam",
data() {
return {
team: {title: ''},
error: '',
loading: false
teamService: TeamService,
team: TeamModel,
}
},
beforeMount() {
@ -45,32 +45,23 @@
}
},
created() {
this.teamService = new TeamService()
this.$parent.setFullPage();
},
methods: {
newTeam() {
const cancel = message.setLoading(this)
HTTP.put(`teams`, this.team, {headers: {'Authorization': 'Bearer ' + localStorage.getItem('token')}})
this.teamService.create(this.team)
.then(response => {
router.push({name:'editTeam', params:{id: response.data.id}})
this.handleSuccess({message: 'The team was successfully created.'})
cancel()
router.push({name:'editTeam', params:{id: response.id}})
message.success({message: 'The team was successfully created.'}, this)
})
.catch(e => {
cancel()
this.handleError(e)
message.error(e, this)
})
},
back() {
router.go(-1)
},
handleError(e) {
message.error(e, this)
},
handleSuccess(e) {
message.success(e, this)
}
}
}
</script>

View file

@ -52,6 +52,7 @@
},
beforeMount() {
// Try to verify the email
// FIXME: Why is this here? Can we find a better place for this?
let emailVerifyToken = localStorage.getItem('emailConfirmToken')
if (emailVerifyToken) {
const cancel = message.setLoading(this)

View file

@ -16,10 +16,10 @@
<div class="field is-grouped">
<div class="control">
<button type="submit" class="button is-primary" v-bind:class="{ 'is-loading': loading}">Reset your password</button>
<button type="submit" class="button is-primary" :class="{ 'is-loading': this.passwordResetService.loading}">Reset your password</button>
</div>
</div>
<div class="notification is-info" v-if="loading">
<div class="notification is-info" v-if="this.passwordResetService.loading">
Loading...
</div>
<div class="notification is-danger" v-if="error">
@ -37,53 +37,42 @@
</template>
<script>
import {HTTP} from '../../http-common'
import message from '../../message'
import PasswordResetModel from '../../models/passwordReset'
import PasswordResetService from '../../services/passwordReset'
export default {
data() {
return {
passwordResetService: PasswordResetService,
credentials: {
password: '',
password2: '',
},
error: '',
successMessage: '',
loading: false
successMessage: ''
}
},
created() {
this.passwordResetService = new PasswordResetService()
},
methods: {
submit() {
const cancel = message.setLoading(this)
this.error = ''
if (this.credentials.password2 !== this.credentials.password) {
cancel()
this.error = 'Passwords don\'t match'
return
}
let resetPasswordPayload = {
token: localStorage.getItem('passwordResetToken'),
new_password: this.credentials.password
}
HTTP.post(`user/password/reset`, resetPasswordPayload)
let passwordReset = new PasswordResetModel({new_password: this.credentials.password})
this.passwordResetService.resetPassword(passwordReset)
.then(response => {
this.handleSuccess(response)
this.successMessage = response.data.message
localStorage.removeItem('passwordResetToken')
cancel()
})
.catch(e => {
this.error = e.response.data.message
cancel()
})
},
handleError(e) {
this.error = e.response.data.message
},
handleSuccess(e) {
this.successMessage = e.data.message
}
}
}

View file

@ -5,13 +5,13 @@
<form id="loginform" @submit.prevent="submit" v-if="!isSuccess">
<div class="field">
<div class="control">
<input v-focus type="text" class="input" name="email" placeholder="Email-Adress" v-model="email" required>
<input v-focus type="text" class="input" name="email" placeholder="Email-Adress" v-model="passwordReset.email" required>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button type="submit" class="button is-primary" v-bind:class="{ 'is-loading': loading}">Send me a password reset link</button>
<button type="submit" class="button is-primary" v-bind:class="{ 'is-loading': passwordResetService.loading}">Send me a password reset link</button>
<router-link :to="{ name: 'login' }" class="button">Login</router-link>
</div>
</div>
@ -30,38 +30,32 @@
</template>
<script>
import {HTTP} from '../../http-common'
import message from '../../message'
import PasswordResetModel from '../../models/passwordReset'
import PasswordResetService from '../../services/passwordReset'
export default {
data() {
return {
email: '',
passwordResetService: PasswordResetService,
passwordReset: PasswordResetModel,
error: '',
isSuccess: false,
loading: false
isSuccess: false
}
},
created() {
this.passwordResetService = new PasswordResetService()
this.passwordReset = new PasswordResetModel()
},
methods: {
submit() {
const cancel = message.setLoading(this)
this.error = ''
let credentials = {
email: this.email,
}
HTTP.post(`user/password/token`, credentials)
this.passwordResetService.requestResetPassword(this.passwordReset)
.then(() => {
cancel()
this.isSuccess = true
})
.catch(e => {
cancel()
this.handleError(e)
})
},
handleError(e) {
this.error = e.response.data.message
})
},
}
}

View file

@ -0,0 +1,21 @@
import {defaults, omitBy, isNil} from 'lodash'
export default class AbstractModel {
/**
* The abstract constructor takes an object and merges its data with the default data of this model.
* @param data
*/
constructor(data) {
// Put all data in our model while overriding those with a value of null or undefined with their defaults
defaults(this, omitBy(data, isNil), this.defaults())
}
/**
* Default attributes that define the "empty" state.
* @return {{}}
*/
defaults() {
return {}
}
}

1
src/models/config.js Normal file
View file

@ -0,0 +1 @@
export const URL_PREFIX = '/api/v1' // _without_ slash at the end

111
src/models/list.js Normal file
View file

@ -0,0 +1,111 @@
import AbstractModel from './abstractModel'
import TaskModel from './task'
import UserModel from './user'
export default class ListModel extends AbstractModel {
constructor(data) {
super(data)
// Make all tasks to task models
this.tasks = this.tasks.map(t => {
return new TaskModel(t)
})
this.owner = new UserModel(this.owner)
this.sortTasks()
}
// Default attributes that define the "empty" state.
defaults() {
return {
id: 0,
title: '',
description: '',
owner: UserModel,
tasks: [],
namespaceID: 0,
created: 0,
updated: 0,
}
}
////////
// Helpers
//////
/**
* Sorts all tasks according to their due date
* @returns {this}
*/
sortTasks() {
if (this.tasks === null || this.tasks === []) {
return
}
return this.tasks.sort(function(a,b) {
if (a.done < b.done)
return -1
if (a.done > b.done)
return 1
return 0
})
}
/**
* Adds a task to the task array of this list. Usually only used when creating a new task
* @param task
*/
addTaskToList(task) {
// If it's a subtask, add it to its parent, otherwise append it to the list of tasks
if (task.parentTaskID === 0) {
this.tasks.push(task)
} else {
for (const t in this.tasks) {
if (this.tasks[t].id === task.parentTaskID) {
this.tasks[t].subtasks.push(task)
break
}
}
}
this.sortTasks()
}
/**
* Gets a task by its ID by looping through all tasks.
* @param id
* @returns {TaskModel}
*/
getTaskByID(id) {
// TODO: Binary search?
for (const t in this.tasks) {
if (this.tasks[t].id === parseInt(id)) {
return this.tasks[t]
}
}
return {} // FIXME: This should probably throw something to make it clear to the user noting was found
}
/**
* Loops through all tasks and updates the one with the id it has
* @param task
*/
updateTaskByID(task) {
for (const t in this.tasks) {
if (this.tasks[t].id === task.id) {
this.tasks[t] = task
break
}
if (this.tasks[t].id === task.parentTaskID) {
for (const s in this.tasks[t].subtasks) {
if (this.tasks[t].subtasks[s].id === task.id) {
this.tasks[t].subtasks[s] = task
break
}
}
}
}
this.sortTasks()
}
}

28
src/models/namespace.js Normal file
View file

@ -0,0 +1,28 @@
import AbstractModel from './abstractModel'
import ListModel from './list'
import UserModel from './user'
export default class NamespaceModel extends AbstractModel {
constructor(data) {
super(data)
this.lists = this.lists.map(l => {
return new ListModel(l)
})
this.owner = new UserModel(this.owner)
}
// Default attributes that define the 'empty' state.
defaults() {
return {
id: 0,
name: '',
description: '',
owner: UserModel,
lists: [],
created: 0,
updated: 0,
}
}
}

View file

@ -0,0 +1,17 @@
import AbstractModel from "./abstractModel";
export default class PasswordResetModel extends AbstractModel {
constructor(data) {
super(data)
this.token = localStorage.getItem('passwordResetToken')
}
defaults() {
return {
token: '',
new_password: '',
email: '',
}
}
}

99
src/models/task.js Normal file
View file

@ -0,0 +1,99 @@
import AbstractModel from './abstractModel';
import UserModel from './user'
export default class TaskModel extends AbstractModel {
constructor(data) {
super(data)
// Make date objects from timestamps
this.dueDate = this.parseDateIfNessecary(this.dueDate)
this.startDate = this.parseDateIfNessecary(this.startDate)
this.endDate = this.parseDateIfNessecary(this.endDate)
this.reminderDates = this.reminderDates.map(d => {
return this.parseDateIfNessecary(d)
})
this.reminderDates.push(null) // To trigger the datepicker
// Parse the repeat after into something usable
this.parseRepeatAfter()
// Parse the assignees into user models
this.assignees = this.assignees.map(a => {
return new UserModel(a)
})
this.createdBy = new UserModel(this.createdBy)
}
defaults() {
return {
id: 0,
text: '',
description: '',
done: false,
priority: 0,
labels: [],
assignees: [],
dueDate: 0,
startDate: 0,
endDate: 0,
repeatAfter: 0,
reminderDates: [],
subtasks: [],
parentTaskID: 0,
createdBy: UserModel,
created: 0,
updated: 0,
listID: 0, // Meta, only used when creating a new task
sortBy: 'duedate', // Meta, only used when listing all tasks
}
}
/////////////////
// Helper functions
///////////////
/**
* Makes a js date object from a unix timestamp (in seconds).
* @param unixTimestamp
* @returns {*}
*/
parseDateIfNessecary(unixTimestamp) {
let dateobj = new Date(unixTimestamp * 1000)
if (unixTimestamp === 0) {
return null
}
return dateobj
}
/**
* Parses the "repeat after x seconds" from the task into a usable js object inside the task.
* This function should only be called from the constructor.
*/
parseRepeatAfter() {
let repeatAfterHours = (this.repeatAfter / 60) / 60
this.repeatAfter = {type: 'hours', amount: repeatAfterHours}
// if its dividable by 24, its something with days, otherwise hours
if (repeatAfterHours % 24 === 0) {
let repeatAfterDays = repeatAfterHours / 24
if (repeatAfterDays % 7 === 0) {
this.repeatAfter.type = 'weeks'
this.repeatAfter.amount = repeatAfterDays / 7
} else if (repeatAfterDays % 30 === 0) {
this.repeatAfter.type = 'months'
this.repeatAfter.amount = repeatAfterDays / 30
} else if (repeatAfterDays % 365 === 0) {
this.repeatAfter.type = 'years'
this.repeatAfter.amount = repeatAfterDays / 365
} else {
this.repeatAfter.type = 'days'
this.repeatAfter.amount = repeatAfterDays
}
}
}
}

29
src/models/team.js Normal file
View file

@ -0,0 +1,29 @@
import AbstractModel from './abstractModel'
import UserModel from './user'
import TeamMemberModel from './teamMember'
export default class TeamModel extends AbstractModel {
constructor(data) {
super(data)
// Make the members to usermodels
this.members = this.members.map(m => {
return new TeamMemberModel(m)
})
this.createdBy = new UserModel(this.createdBy)
}
defaults() {
return {
id: 0,
name: '',
description: '',
members: [],
right: 0,
createdBy: {},
created: 0,
updated: 0
}
}
}

13
src/models/teamList.js Normal file
View file

@ -0,0 +1,13 @@
import TeamShareBaseModel from './teamShareBase'
import {merge} from "lodash";
export default class TeamListModel extends TeamShareBaseModel {
defaults() {
return merge(
super.defaults(),
{
listID: 0,
}
)
}
}

14
src/models/teamMember.js Normal file
View file

@ -0,0 +1,14 @@
import UserModel from './user'
import {merge} from 'lodash'
export default class TeamMemberModel extends UserModel {
defaults() {
return merge(
super.defaults(),
{
admin: false,
teamID: 0,
}
)
}
}

View file

@ -0,0 +1,13 @@
import TeamShareBaseModel from './teamShareBase'
import {merge} from 'lodash'
export default class TeamNamespaceModel extends TeamShareBaseModel {
defaults() {
return merge(
super.defaults(),
{
namespaceID: 0,
}
)
}
}

View file

@ -0,0 +1,17 @@
import AbstractModel from './abstractModel'
/**
* This class is a base class for common team sharing model.
* It is extended in a way so it can be used for namespaces as well for lists.
*/
export default class TeamShareBaseModel extends AbstractModel {
defaults() {
return {
teamID: 0,
right: 0,
created: 0,
updated: 0
}
}
}

13
src/models/user.js Normal file
View file

@ -0,0 +1,13 @@
import AbstractModel from './abstractModel'
export default class UserModel extends AbstractModel {
defaults() {
return {
id: 0,
email: '',
username: '',
created: 0,
updated: 0
}
}
}

14
src/models/userList.js Normal file
View file

@ -0,0 +1,14 @@
import UserShareBaseModel from './userShareBase'
import {merge} from 'lodash'
// This class extends the user share model with a 'rights' parameter which is used in sharing
export default class UserListModel extends UserShareBaseModel {
defaults() {
return merge(
super.defaults(),
{
listID: 0,
}
)
}
}

View file

@ -0,0 +1,14 @@
import UserShareBaseModel from "./userShareBase";
import {merge} from 'lodash'
// This class extends the user share model with a 'rights' parameter which is used in sharing
export default class UserNamespaceModel extends UserShareBaseModel {
defaults() {
return merge(
super.defaults(),
{
namespaceID: 0,
}
)
}
}

View file

@ -0,0 +1,13 @@
import AbstractModel from './abstractModel'
export default class UserShareBaseModel extends AbstractModel {
defaults() {
return {
userID: 0,
right: 0,
created: 0,
updated: 0,
}
}
}

View file

@ -0,0 +1,354 @@
import axios from 'axios'
import {reduce, replace} from 'lodash'
let config = require('../../public/config.json')
export default class AbstractService {
/////////////////////////////
// Initial variable definitions
///////////////////////////
http = null
loading = false
paths = {
create: '',
get: '',
getAll: '',
update: '',
delete: '',
}
/////////////
// Service init
///////////
/**
* The abstract constructor.
* @param paths An object with all paths. Default values are specified above.
*/
constructor(paths) {
this.http = axios.create({
baseURL: config.VIKUNJA_API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
})
// Set the default auth header if we have a token
if (
localStorage.getItem('token') !== '' &&
localStorage.getItem('token') !== null &&
localStorage.getItem('token') !== undefined
) {
this.http.defaults.headers.common['Authorization'] = 'Bearer ' + localStorage.getItem('token')
}
this.paths = {
create: paths.create !== undefined ? paths.create : '',
get: paths.get !== undefined ? paths.get : '',
getAll: paths.getAll !== undefined ? paths.getAll : '',
update: paths.update !== undefined ? paths.update : '',
delete: paths.delete !== undefined ? paths.delete : '',
}
}
/////////////////////
// Global error handler
///////////////////
/**
* Handles the error and rejects the promise.
* @param error
* @returns {Promise<never>}
*/
errorHandler(error) {
return Promise.reject(error)
}
/////////////////
// Helper functions
///////////////
/**
* Returns an object with all route parameters and their values.
* @param route
* @returns object
*/
getRouteReplacements(route) {
let parameters = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}
let replace$$1 = {}
let pattern = this.getRouteParameterPattern()
pattern = new RegExp(pattern instanceof RegExp ? pattern.source : pattern, 'g')
for (let parameter; (parameter = pattern.exec(route)) !== null;) {
replace$$1[parameter[0]] = parameters[parameter[1]];
}
return replace$$1;
}
/**
* Holds the replacement pattern for url paths, can be overwritten by implementations.
* @return {RegExp}
*/
getRouteParameterPattern() {
return /{([^}]+)}/
}
/**
* Returns a fully-ready-ready-to-make-a-request-to route with replaced parameters.
* @param path
* @param pathparams
* @return string
*/
getReplacedRoute(path, pathparams) {
let replacements = this.getRouteReplacements(path, pathparams)
return reduce(replacements, function (result, value, parameter) {
return replace(result, parameter, value)
}, path)
}
/**
* setLoading is a method which sets the loading variable to true, after a timeout of 100ms.
* It has the timeout to prevent the loading indicator from showing for only a blink of an eye in the
* case the api returns a response in < 100ms.
* But because the timeout is created using setTimeout, it will still trigger even if the request is
* already finished, so we return a method to call in that case.
* @returns {Function}
*/
setLoading() {
const timeout = setTimeout(() => {
this.loading = true
}, 100)
return () => {
clearTimeout(timeout)
this.loading = false
}
}
//////////////////
// Default factories
// It is possible to specify a factory for each type of request.
// This makes it possible to have different models returned from different routes.
// Specific factories for each request are completly optional, if these are not specified, the defautl factory is used.
////////////////
/**
* The modelFactory returns an model from an object.
* This one here is the default one, usually the service definitions for a model will override this.
* @param data
* @returns {*}
*/
modelFactory(data) {
return data
}
/**
* This is the model factory for get requests.
* @param data
* @return {*}
*/
modelGetFactory(data) {
return this.modelFactory(data)
}
/**
* This is the model factory for get all requests.
* @param data
* @return {*}
*/
modelGetAllFactory(data) {
return this.modelFactory(data)
}
/**
* This is the model factory for create requests.
* @param data
* @return {*}
*/
modelCreateFactory(data) {
return this.modelFactory(data)
}
/**
* This is the model factory for update requests.
* @param data
* @return {*}
*/
modelUpdateFactory(data) {
return this.modelFactory(data)
}
//////////////
// Preprocessors
////////////
/**
* Default preprocessor for get requests
* @param model
* @return {*}
*/
beforeGet(model) {
return model
}
/**
* Default preprocessor for create requests
* @param model
* @return {*}
*/
beforeCreate(model) {
return model
}
/**
* Default preprocessor for update requests
* @param model
* @return {*}
*/
beforeUpdate(model) {
return model
}
/**
* Default preprocessor for delete requests
* @param model
* @return {*}
*/
beforeDelete(model) {
return model
}
///////////////
// Global actions
/////////////
/**
* Performs a get request to the url specified before.
* @param model The model to use. The request path is built using the values from the model.
* @param params Optional query parameters
* @returns {Q.Promise<any>}
*/
get(model, params = {}) {
if (this.paths.get === '') {
return Promise.reject({message: 'This model is not able to get data.'})
}
const cancel = this.setLoading()
model = this.beforeGet(model)
return this.http.get(this.getReplacedRoute(this.paths.get, model), {params: params})
.catch(error => {
return this.errorHandler(error)
})
.then(response => {
return Promise.resolve(this.modelGetFactory(response.data))
})
.finally(() => {
cancel()
})
}
/**
* Performs a get request to the url specified before.
* The difference between this and get() is this one is used to get a bunch of data (an array), not just a single object.
* @param model The model to use. The request path is built using the values from the model.
* @param params Optional query parameters
* @returns {Q.Promise<any>}
*/
getAll(model = {}, params = {}) {
if (this.paths.getAll === '') {
return Promise.reject({message: 'This model is not able to get data.'})
}
const cancel = this.setLoading()
model = this.beforeGet(model)
return this.http.get(this.getReplacedRoute(this.paths.getAll, model), {params: params})
.catch(error => {
return this.errorHandler(error)
})
.then(response => {
if (Array.isArray(response.data)) {
return Promise.resolve(response.data.map(entry => {
return this.modelGetAllFactory(entry)
}))
}
return Promise.resolve(this.modelGetAllFactory(response.data))
})
.finally(() => {
cancel()
})
}
/**
* Performs a put request to the url specified before
* @param model
* @returns {Promise<any | never>}
*/
create(model) {
if (this.paths.create === '') {
return Promise.reject({message: 'This model is not able to create data.'})
}
const cancel = this.setLoading()
model = this.beforeCreate(model)
return this.http.put(this.getReplacedRoute(this.paths.create, model), model)
.catch(error => {
return this.errorHandler(error)
})
.then(response => {
return Promise.resolve(this.modelCreateFactory(response.data))
})
.finally(() => {
cancel()
})
}
/**
* Performs a post request to the update url
* @param model
* @returns {Q.Promise<any>}
*/
update(model) {
if (this.paths.update === '') {
return Promise.reject({message: 'This model is not able to update data.'})
}
const cancel = this.setLoading()
model = this.beforeUpdate(model)
return this.http.post(this.getReplacedRoute(this.paths.update, model), model)
.catch(error => {
return this.errorHandler(error)
})
.then(response => {
return Promise.resolve(this.modelUpdateFactory(response.data))
})
.finally(() => {
cancel()
})
}
/**
* Performs a delete request to the update url
* @param model
* @returns {Q.Promise<any>}
*/
delete(model) {
if (this.paths.delete === '') {
return Promise.reject({message: 'This model is not able to delete data.'})
}
const cancel = this.setLoading()
model = this.beforeUpdate(model)
return this.http.delete(this.getReplacedRoute(this.paths.delete, model), model)
.catch(error => {
return this.errorHandler(error)
})
.then(response => {
return Promise.resolve(response.data)
})
.finally(() => {
cancel()
})
}
}

26
src/services/list.js Normal file
View file

@ -0,0 +1,26 @@
import AbstractService from './abstractService'
import ListModel from '../models/list'
import TaskService from './task'
export default class ListService extends AbstractService {
constructor() {
super({
create: '/namespaces/{namespaceID}/lists',
get: '/lists/{id}',
update: '/lists/{id}',
delete: '/lists/{id}',
})
}
modelFactory(data) {
return new ListModel(data)
}
beforeUpdate(model) {
let taskService = new TaskService()
model.tasks = model.tasks.map(task => {
return taskService.beforeUpdate(task)
})
return model
}
}

18
src/services/namespace.js Normal file
View file

@ -0,0 +1,18 @@
import AbstractService from './abstractService'
import NamespaceModel from '../models/namespace'
export default class NamespaceService extends AbstractService {
constructor() {
super({
create: '/namespaces',
get: '/namespaces/{id}',
getAll: '/namespaces',
update: '/namespaces/{id}',
delete: '/namespaces/{id}',
});
}
modelFactory(data) {
return new NamespaceModel(data)
}
}

View file

@ -0,0 +1,45 @@
import AbstractService from './abstractService'
import PasswordResetModel from '../models/passwordReset'
export default class PasswordResetService extends AbstractService {
constructor() {
super({})
this.paths = {
reset: '/user/password/reset',
requestReset: '/user/password/token',
}
}
modelFactory(data) {
return new PasswordResetModel(data)
}
resetPassword(model) {
const cancel = this.setLoading()
return this.http.post(this.paths.reset, model)
.catch(error => {
return this.errorHandler(error)
})
.then(response => {
return Promise.resolve(this.modelFactory(response.data))
})
.finally(() => {
cancel()
})
}
requestResetPassword(model) {
const cancel = this.setLoading()
return this.http.post(this.paths.requestReset, model)
.catch(error => {
return this.errorHandler(error)
})
.then(response => {
return Promise.resolve(this.modelFactory(response.data))
})
.finally(() => {
cancel()
})
}
}

61
src/services/task.js Normal file
View file

@ -0,0 +1,61 @@
import AbstractService from './abstractService'
import TaskModel from '../models/task'
export default class TaskService extends AbstractService {
constructor() {
super({
create: '/lists/{listID}',
getAll: '/tasks/all/{sortBy}/{startDate}/{endDate}',
update: '/tasks/{id}',
delete: '/tasks/{id}',
});
}
modelFactory(data) {
return new TaskModel(data)
}
beforeUpdate(model) {
// Convert the date in a unix timestamp
model.dueDate = (+ new Date(model.dueDate)) / 1000
model.startDate = (+ new Date(model.startDate)) / 1000
model.endDate = (+ new Date(model.endDate)) / 1000
// remove all nulls, these would create empty reminders
for (const index in model.reminderDates) {
if (model.reminderDates[index] === null) {
model.reminderDates.splice(index, 1)
}
}
// Make normal timestamps from js dates
model.reminderDates = model.reminderDates.map(r => {
return Math.round(r / 1000)
})
// Make the repeating amount to seconds
let repeatAfterSeconds = 0
if (model.repeatAfter.amount !== null || model.repeatAfter.amount !== 0) {
switch (model.repeatAfter.type) {
case 'hours':
repeatAfterSeconds = model.repeatAfter.amount * 60 * 60
break
case 'days':
repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 * 24
break
case 'weeks':
repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 * 24 * 7
break
case 'months':
repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 * 24 * 30
break
case 'years':
repeatAfterSeconds = model.repeatAfter.amount * 60 * 60 * 24 * 365
break
}
}
model.repeatAfter = repeatAfterSeconds
return model
}
}

18
src/services/team.js Normal file
View file

@ -0,0 +1,18 @@
import AbstractService from './abstractService'
import TeamModel from '../models/team'
export default class TeamService extends AbstractService {
constructor() {
super({
create: '/teams',
get: '/teams/{id}',
getAll: '/teams',
update: '/teams/{id}',
delete: '/teams/{id}',
});
}
modelFactory(data) {
return new TeamModel(data)
}
}

22
src/services/teamList.js Normal file
View file

@ -0,0 +1,22 @@
import AbstractService from './abstractService'
import TeamListModel from '../models/teamList'
import TeamModel from '../models/team'
export default class TeamListService extends AbstractService {
constructor() {
super({
create: '/lists/{listID}/teams',
getAll: '/lists/{listID}/teams',
update: '/lists/{listID}/teams/{teamID}',
delete: '/lists/{listID}/teams/{teamID}',
})
}
modelFactory(data) {
return new TeamListModel(data)
}
modelGetAllFactory(data) {
return new TeamModel(data)
}
}

View file

@ -0,0 +1,21 @@
import AbstractService from './abstractService'
import TeamMemberModel from '../models/teamMember'
export default class TeamMemberService extends AbstractService {
constructor() {
super({
create: '/teams/{teamID}/members',
delete: '/teams/{teamID}/members/{id}', // "id" is the user id because we're intheriting from a normal user
});
}
modelFactory(data) {
return new TeamMemberModel(data)
}
beforeCreate(model) {
model.userID = model.id // The api wants to get the user id as userID
model.admin = model.admin === null ? false : model.admin
return model
}
}

View file

@ -0,0 +1,22 @@
import AbstractService from './abstractService'
import TeamNamespaceModel from '../models/teamNamespace'
import TeamModel from '../models/team'
export default class TeamNamespaceService extends AbstractService {
constructor() {
super({
create: '/namespaces/{namespaceID}/teams',
getAll: '/namespaces/{namespaceID}/teams',
update: '/namespaces/{namespaceID}/teams/{teamID}',
delete: '/namespaces/{namespaceID}/teams/{teamID}',
})
}
modelFactory(data) {
return new TeamNamespaceModel(data)
}
modelGetAllFactory(data) {
return new TeamModel(data)
}
}

14
src/services/user.js Normal file
View file

@ -0,0 +1,14 @@
import AbstractService from './abstractService'
import UserModel from '../models/user'
export default class UserService extends AbstractService {
constructor() {
super({
getAll: '/users'
})
}
modelFactory(data) {
return new UserModel(data)
}
}

22
src/services/userList.js Normal file
View file

@ -0,0 +1,22 @@
import AbstractService from './abstractService'
import UserListModel from '../models/userList'
import UserModel from '../models/user'
export default class UserListService extends AbstractService {
constructor() {
super({
create: '/lists/{listID}/users',
getAll: '/lists/{listID}/users',
update: '/lists/{listID}/users/{userID}',
delete: '/lists/{listID}/users/{userID}',
})
}
modelFactory(data) {
return new UserListModel(data)
}
modelGetAllFactory(data) {
return new UserModel(data)
}
}

View file

@ -0,0 +1,22 @@
import AbstractService from './abstractService'
import UserNamespaceModel from '../models/userNamespace'
import UserModel from '../models/user'
export default class UserNamespaceService extends AbstractService {
constructor() {
super({
create: '/namespaces/{namespaceID}/users',
getAll: '/namespaces/{namespaceID}/users',
update: '/namespaces/{namespaceID}/users/{userID}',
delete: '/namespaces/{namespaceID}/users/{userID}',
})
}
modelFactory(data) {
return new UserNamespaceModel(data)
}
modelGetAllFactory(data) {
return new UserModel(data)
}
}

27
todo.md
View file

@ -77,6 +77,9 @@
* [x] Einzelne Teams ansehbar
* [x] In den Teams, in denen der Nutzer admin ist, Bearbeitung ermöglichen
* [x] Löschen ermöglichen
* [x] Subtasks
* [x] Start/Enddatum für Tasks
* [x] Tasks in time range
* [ ] Search everything
* [ ] Lists
* [ ] Tasks
@ -86,21 +89,31 @@
* [ ] Users with access to a namespace
* [ ] Teams with access to a list
* [ ] Teams with access to a namespace
* [x] Subtasks
* [x] Start/Enddatum für Tasks
* [x] Tasks in time range
* [ ] Priorities
* [ ] Sachen mit hoher Prio irgendwie hervorheben (rotes Dreieck zb)
* [ ] Listen Kopieren
* [ ] "Move to Vikunja" -> Migrator von Wunderlist/todoist/etc
* [ ] Assignees
* [ ] Labels
* [ ] Timeline/Calendar view -> Dazu tasks die in einem Bestimmten Bereich due sind, macht dann das Frontend
## Other features
* [ ] Copy lists
* [ ] "Move to Vikunja" -> Migrator von Wunderlist/todoist/etc
## Refactor
* [x] Move everything to models
* [x] Make sure all loading properties are depending on its service
* [x] Fix the first request afer login being made with an old token
* [ ] Team sharing
* [ ] Refactor team sharing to not make a new request every time something was changed
* [ ] Team sharing should be able to search for a team instead of its ID, like it's the case with users
* [ ] Dropdown for rights
## Waiting for backend
* [ ] Assignees
* [ ] In und Out webhooks, mit Templates vom Payload
* [ ] "Smart Lists", Listen nach bestimmten Kriterien gefiltert -> nur UI?
* [ ] "Performance-Statistik" -> Wie viele Tasks man in bestimmten Zeiträumen so geschafft hat etc
* [ ] Activity Feed, so à la "der und der hat das und das gemacht etc"
* [ ] Attachments for tasks
* [ ] Labels

View file

@ -2710,7 +2710,7 @@ debug@^4.1.0:
dependencies:
ms "^2.1.1"
debuglog@*, debuglog@^1.0.1:
debuglog@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492"
integrity sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI=
@ -4365,7 +4365,7 @@ import-local@^2.0.0:
pkg-dir "^3.0.0"
resolve-cwd "^2.0.0"
imurmurhash@*, imurmurhash@^0.1.4:
imurmurhash@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
@ -5165,11 +5165,6 @@ lockfile@^1.0.4:
dependencies:
signal-exit "^3.0.2"
lodash._baseindexof@*:
version "3.1.0"
resolved "https://registry.yarnpkg.com/lodash._baseindexof/-/lodash._baseindexof-3.1.0.tgz#fe52b53a1c6761e42618d654e4a25789ed61822c"
integrity sha1-/lK1OhxnYeQmGNZU5KJXie1hgiw=
lodash._baseuniq@~4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz#0ebb44e456814af7905c6212fa2c9b2d51b841e8"
@ -5178,33 +5173,11 @@ lodash._baseuniq@~4.6.0:
lodash._createset "~4.0.0"
lodash._root "~3.0.0"
lodash._bindcallback@*:
version "3.0.1"
resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e"
integrity sha1-5THCdkTPi1epnhftlbNcdIeJOS4=
lodash._cacheindexof@*:
version "3.0.2"
resolved "https://registry.yarnpkg.com/lodash._cacheindexof/-/lodash._cacheindexof-3.0.2.tgz#3dc69ac82498d2ee5e3ce56091bafd2adc7bde92"
integrity sha1-PcaayCSY0u5ePOVgkbr9Ktx73pI=
lodash._createcache@*:
version "3.1.2"
resolved "https://registry.yarnpkg.com/lodash._createcache/-/lodash._createcache-3.1.2.tgz#56d6a064017625e79ebca6b8018e17440bdcf093"
integrity sha1-VtagZAF2JeeevKa4AY4XRAvc8JM=
dependencies:
lodash._getnative "^3.0.0"
lodash._createset@~4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/lodash._createset/-/lodash._createset-4.0.3.tgz#0f4659fbb09d75194fa9e2b88a6644d363c9fe26"
integrity sha1-D0ZZ+7CddRlPqeK4imZE02PJ/iY=
lodash._getnative@*, lodash._getnative@^3.0.0:
version "3.9.1"
resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5"
integrity sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=
lodash._root@~3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/lodash._root/-/lodash._root-3.0.1.tgz#fba1c4524c19ee9a5f8136b4609f017cf4ded692"
@ -5250,11 +5223,6 @@ lodash.mergewith@^4.6.0:
resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz#639057e726c3afbdb3e7d42741caa8d6e4335927"
integrity sha512-eWw5r+PYICtEBgrBE5hhlT6aAa75f411bgDz/ZL2KZqYV03USvucsxcHUIlGTDTECs1eunpI7HOV7U+WLDvNdQ==
lodash.restparam@*:
version "3.6.1"
resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805"
integrity sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=
lodash.tail@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664"
@ -7384,7 +7352,7 @@ readable-stream@~1.1.10:
isarray "0.0.1"
string_decoder "~0.10.x"
readdir-scoped-modules@*, readdir-scoped-modules@^1.0.0:
readdir-scoped-modules@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.0.2.tgz#9fafa37d286be5d92cbaebdee030dc9b5f406747"
integrity sha1-n6+jfShr5dksuuve4DDcm19AZ0c=