mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-16 06:07:21 +01:00
* removing file that only contained a reference to a missing folder * fixing typo * using full dates to avoid moment warning in tests * more typos * sending an empty string to vue bootstrap tooltip (disabling it) if no challenge short name is set
665 lines
19 KiB
Vue
665 lines
19 KiB
Vue
<template lang="pug">
|
|
.tasks-column(:class='type')
|
|
b-modal(ref="editTaskModal")
|
|
buy-quest-modal(:item="selectedItemToBuy || {}",
|
|
:priceType="selectedItemToBuy ? selectedItemToBuy.currency : ''",
|
|
:withPin="true",
|
|
@change="resetItemToBuy($event)"
|
|
v-if='type === "reward"')
|
|
.d-flex
|
|
h2.tasks-column-title
|
|
| {{ $t(typeLabel) }}
|
|
.badge.badge-pill.badge-purple.column-badge(v-if="badgeCount > 0") {{ badgeCount }}
|
|
.filters.d-flex.justify-content-end
|
|
.filter.small-text(
|
|
v-for="filter in typeFilters",
|
|
:class="{active: activeFilter.label === filter}",
|
|
@click="activateFilter(type, filter)",
|
|
) {{ $t(filter) }}
|
|
.tasks-list(ref="tasksWrapper")
|
|
textarea.quick-add(
|
|
:rows="quickAddRows",
|
|
v-if="isUser", :placeholder="quickAddPlaceholder",
|
|
v-model="quickAddText", @keypress.enter="quickAdd",
|
|
ref="quickAdd",
|
|
@focus="quickAddFocused = true", @blur="quickAddFocused = false",
|
|
)
|
|
transition(name="quick-add-tip-slide")
|
|
.quick-add-tip.small-text(v-show="quickAddFocused", v-html="$t('addMultipleTip')")
|
|
clear-completed-todos(v-if="activeFilter.label === 'complete2' && isUser === true")
|
|
.column-background(
|
|
v-if="isUser === true",
|
|
:class="{'initial-description': initialColumnDescription}",
|
|
ref="columnBackground",
|
|
)
|
|
.svg-icon(v-html="icons[type]", :class="`icon-${type}`", v-once)
|
|
h3(v-once) {{$t('theseAreYourTasks', {taskType: $t(typeLabel)})}}
|
|
.small-text {{$t(`${type}sDesc`)}}
|
|
draggable.sortable-tasks(
|
|
ref="tasksList",
|
|
@update='taskSorted',
|
|
:options='{disabled: activeFilter.label === "scheduled"}',
|
|
class="sortable-tasks"
|
|
)
|
|
task(
|
|
v-for="task in taskList",
|
|
:key="task.id", :task="task",
|
|
:isUser="isUser",
|
|
@editTask="editTask",
|
|
@moveTo="moveTo",
|
|
:group='group',
|
|
)
|
|
template(v-if="hasRewardsList")
|
|
draggable(
|
|
ref="rewardsList",
|
|
@update="rewardSorted",
|
|
@start="rewardDragStart",
|
|
@end="rewardDragEnd",
|
|
class="reward-items",
|
|
)
|
|
shopItem(
|
|
v-for="reward in inAppRewards",
|
|
:item="reward",
|
|
:key="reward.key",
|
|
:highlightBorder="reward.isSuggested",
|
|
:showPopover="showPopovers"
|
|
@click="openBuyDialog(reward)",
|
|
:popoverPosition="'left'"
|
|
)
|
|
template(slot="itemBadge", slot-scope="ctx")
|
|
span.badge.badge-pill.badge-item.badge-svg(
|
|
:class="{'item-selected-badge': ctx.item.pinned, 'hide': !ctx.highlightBorder}",
|
|
@click.prevent.stop="togglePinned(ctx.item)"
|
|
)
|
|
span.svg-icon.inline.icon-12.color(v-html="icons.pin")
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
@import '~client/assets/scss/colors.scss';
|
|
|
|
|
|
.tasks-column {
|
|
min-height: 556px;
|
|
}
|
|
|
|
.sortable-tasks + .reward-items {
|
|
margin-top: 16px;
|
|
}
|
|
|
|
|
|
.reward-items {
|
|
@supports (display: grid) {
|
|
display: grid;
|
|
justify-content: center;
|
|
grid-column-gap: 16px;
|
|
grid-row-gap: 4px;
|
|
grid-template-columns: repeat(auto-fill, 94px);
|
|
}
|
|
|
|
@supports not (display: grid) {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
& > div {
|
|
margin: 0 16px 4px 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
.tasks-list {
|
|
border-radius: 4px;
|
|
background: $gray-600;
|
|
padding: 8px;
|
|
position: relative; // needed for the .bottom-gradient to be position: absolute
|
|
height: calc(100% - 56px);
|
|
padding-bottom: 30px;
|
|
}
|
|
|
|
.quick-add {
|
|
border-radius: 2px;
|
|
background-color: rgba($black, 0.06);
|
|
width: 100%;
|
|
margin-bottom: 3px;
|
|
padding: 12px 16px;
|
|
border-color: transparent;
|
|
transition: background 0.15s ease-in;
|
|
resize: none;
|
|
|
|
&:hover {
|
|
background-color: rgba($black, 0.1);
|
|
border-color: transparent;
|
|
}
|
|
|
|
&:active, &:focus {
|
|
background: $white;
|
|
border-color: $purple-500;
|
|
color: $gray-50;
|
|
margin-bottom: 0px;
|
|
}
|
|
|
|
&::placeholder {
|
|
font-weight: bold;
|
|
}
|
|
}
|
|
|
|
.quick-add-tip {
|
|
font-style: normal;
|
|
padding: 16px;
|
|
text-align: center;
|
|
|
|
overflow-y: hidden;
|
|
max-height: 65px; // approximate max height
|
|
}
|
|
|
|
.quick-add-tip-slide-enter-active {
|
|
transition: all 0.5s cubic-bezier(0, 1, 0.5, 1);
|
|
}
|
|
|
|
.quick-add-tip-slide-leave-active {
|
|
transition: all 0.5s cubic-bezier(0, 1, 0.5, 1);
|
|
}
|
|
|
|
.quick-add-tip-slide-enter, .quick-add-tip-slide-leave-to {
|
|
max-height: 0;
|
|
padding: 0px 16px;
|
|
}
|
|
|
|
.tasks-column-title {
|
|
margin-bottom: 8px;
|
|
position: relative;
|
|
}
|
|
|
|
.column-badge {
|
|
top: -5px;
|
|
right: -24px;
|
|
}
|
|
|
|
.filters {
|
|
flex-grow: 1;
|
|
}
|
|
|
|
.filter {
|
|
font-weight: bold;
|
|
color: $gray-100;
|
|
font-style: normal;
|
|
padding: 8px;
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
color: $purple-200;
|
|
}
|
|
|
|
&.active {
|
|
color: $purple-200;
|
|
border-bottom: 2px solid $purple-200;
|
|
padding-bottom: 6px;
|
|
}
|
|
}
|
|
|
|
.column-background {
|
|
position: absolute;
|
|
bottom: 32px;
|
|
|
|
&.initial-description {
|
|
top: 30%;
|
|
}
|
|
|
|
.svg-icon {
|
|
margin: 0 auto;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
h3, .small-text {
|
|
color: $gray-300;
|
|
text-align: center;
|
|
}
|
|
|
|
h3 {
|
|
font-weight: normal;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.small-text {
|
|
font-style: normal;
|
|
padding-left: 24px;
|
|
padding-right: 24px;
|
|
}
|
|
}
|
|
|
|
.icon-habit {
|
|
width: 30px;
|
|
height: 20px;
|
|
color: $gray-300;
|
|
}
|
|
|
|
.icon-daily {
|
|
width: 30px;
|
|
height: 20px;
|
|
color: $gray-300;
|
|
}
|
|
|
|
.icon-todo {
|
|
width: 20px;
|
|
height: 20px;
|
|
color: $gray-300;
|
|
}
|
|
|
|
.icon-reward {
|
|
width: 26px;
|
|
height: 20px;
|
|
color: $gray-300;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
import Task from './task';
|
|
import ClearCompletedTodos from './clearCompletedTodos';
|
|
import throttle from 'lodash/throttle';
|
|
import isEmpty from 'lodash/isEmpty';
|
|
import buyMixin from 'client/mixins/buy';
|
|
import { mapState, mapActions, mapGetters } from 'client/libs/store';
|
|
import shopItem from '../shops/shopItem';
|
|
import BuyQuestModal from 'client/components/shops/quests/buyQuestModal.vue';
|
|
|
|
import notifications from 'client/mixins/notifications';
|
|
import { shouldDo } from 'common/script/cron';
|
|
import inAppRewards from 'common/script/libs/inAppRewards';
|
|
import spells from 'common/script/content/spells';
|
|
import taskDefaults from 'common/script/libs/taskDefaults';
|
|
|
|
import {
|
|
getTypeLabel,
|
|
getFilterLabels,
|
|
getActiveFilter,
|
|
} from 'client/libs/store/helpers/filterTasks.js';
|
|
|
|
import svgPin from 'assets/svg/pin.svg';
|
|
import habitIcon from 'assets/svg/habit.svg';
|
|
import dailyIcon from 'assets/svg/daily.svg';
|
|
import todoIcon from 'assets/svg/todo.svg';
|
|
import rewardIcon from 'assets/svg/reward.svg';
|
|
import draggable from 'vuedraggable';
|
|
|
|
export default {
|
|
mixins: [buyMixin, notifications],
|
|
components: {
|
|
Task,
|
|
ClearCompletedTodos,
|
|
BuyQuestModal,
|
|
shopItem,
|
|
draggable,
|
|
},
|
|
// Set default values for props
|
|
// allows for better control of props values
|
|
// allows for better control of where this component is called
|
|
props: {
|
|
type: {},
|
|
isUser: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
searchText: {},
|
|
selectedTags: {},
|
|
taskListOverride: {},
|
|
group: {},
|
|
}, // @TODO: maybe we should store the group on state?
|
|
data () {
|
|
const icons = Object.freeze({
|
|
habit: habitIcon,
|
|
daily: dailyIcon,
|
|
todo: todoIcon,
|
|
reward: rewardIcon,
|
|
pin: svgPin,
|
|
});
|
|
|
|
let typeLabel = '';
|
|
let typeFilters = [];
|
|
let activeFilter = {};
|
|
|
|
return {
|
|
typeLabel,
|
|
typeFilters,
|
|
activeFilter,
|
|
|
|
icons,
|
|
openedCompletedTodos: false,
|
|
|
|
forceRefresh: new Date(),
|
|
quickAddText: '',
|
|
quickAddFocused: false,
|
|
quickAddRows: 1,
|
|
showPopovers: true,
|
|
|
|
selectedItemToBuy: {},
|
|
};
|
|
},
|
|
created () {
|
|
// Set Task Column Label
|
|
this.typeLabel = getTypeLabel(this.type);
|
|
// Get Category Filter Labels
|
|
this.typeFilters = getFilterLabels(this.type);
|
|
// Set default filter for task column
|
|
this.activateFilter(this.type);
|
|
},
|
|
computed: {
|
|
...mapState({
|
|
user: 'user.data',
|
|
}),
|
|
...mapGetters({
|
|
getFilteredTaskList: 'tasks:getFilteredTaskList',
|
|
getUnfilteredTaskList: 'tasks:getUnfilteredTaskList',
|
|
getUserPreferences: 'user:preferences',
|
|
getUserBuffs: 'user:buffs',
|
|
}),
|
|
taskList () {
|
|
// @TODO: This should not default to user's tasks. It should require that you pass options in
|
|
let filteredTaskList = this.isUser ?
|
|
this.getFilteredTaskList({
|
|
type: this.type,
|
|
filterType: this.activeFilter.label,
|
|
}) :
|
|
this.taskListOverride;
|
|
|
|
let taggedList = this.filterByTagList(filteredTaskList, this.selectedTags);
|
|
let searchedList = this.filterBySearchText(taggedList, this.searchText);
|
|
|
|
return searchedList;
|
|
},
|
|
inAppRewards () {
|
|
let watchRefresh = this.forceRefresh; // eslint-disable-line
|
|
let rewards = inAppRewards(this.user);
|
|
|
|
// Add season rewards if user is affected
|
|
// @TODO: Add buff coniditional
|
|
const seasonalSkills = {
|
|
snowball: 'salt',
|
|
spookySparkles: 'opaquePotion',
|
|
shinySeed: 'petalFreePotion',
|
|
seafoam: 'sand',
|
|
};
|
|
|
|
for (let key in seasonalSkills) {
|
|
if (this.getUserBuffs(key)) {
|
|
let debuff = seasonalSkills[key];
|
|
let item = Object.assign({}, spells.special[debuff]);
|
|
item.text = item.text();
|
|
item.notes = item.notes();
|
|
item.class = `shop_${key}`;
|
|
rewards.push(item);
|
|
}
|
|
}
|
|
|
|
return rewards;
|
|
},
|
|
hasRewardsList () {
|
|
return this.isUser === true && this.type === 'reward' && this.activeFilter.label !== 'custom';
|
|
},
|
|
initialColumnDescription () {
|
|
// Show the column description in the middle only if there are no elements (tasks or in app items)
|
|
if (this.hasRewardsList) {
|
|
if (this.inAppRewards && this.inAppRewards.length >= 0) return false;
|
|
}
|
|
|
|
return this.taskList.length === 0;
|
|
},
|
|
quickAddPlaceholder () {
|
|
const type = this.$t(this.type);
|
|
return this.$t('addATask', {type});
|
|
},
|
|
badgeCount () {
|
|
// 0 means the badge will not be shown
|
|
// It is shown for the all and due views of dailies
|
|
// and for the active and scheduled views of todos.
|
|
if (this.type === 'todo' && this.activeFilter.label !== 'complete2') {
|
|
return this.taskList.length;
|
|
} else if (this.type === 'daily') {
|
|
if (this.activeFilter.label === 'due') {
|
|
return this.taskList.length;
|
|
} else if (this.activeFilter.label === 'all') {
|
|
return this.taskList.reduce((count, t) => {
|
|
return !t.completed && shouldDo(new Date(), t, this.getUserPreferences) ? count + 1 : count;
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
},
|
|
},
|
|
watch: {
|
|
taskList: {
|
|
handler: throttle(function setColumnBackgroundVisibility () {
|
|
this.setColumnBackgroundVisibility();
|
|
}, 250),
|
|
deep: true,
|
|
},
|
|
quickAddFocused (newValue) {
|
|
if (newValue) this.quickAddRows = this.quickAddText.split('\n').length;
|
|
if (!newValue) this.quickAddRows = 1;
|
|
},
|
|
},
|
|
mounted () {
|
|
this.setColumnBackgroundVisibility();
|
|
|
|
this.$root.$on('buyModal::boughtItem', () => {
|
|
this.forceRefresh = new Date();
|
|
});
|
|
|
|
if (this.type !== 'todo') return;
|
|
this.$root.$on('habitica::resync-requested', () => {
|
|
if (this.activeFilters.todo.label !== 'complete2') return;
|
|
this.loadCompletedTodos(true);
|
|
});
|
|
},
|
|
destroyed () {
|
|
this.$root.$off('buyModal::boughtItem');
|
|
if (this.type !== 'todo') return;
|
|
this.$root.$off('habitica::resync-requested');
|
|
},
|
|
methods: {
|
|
...mapActions({
|
|
loadCompletedTodos: 'tasks:fetchCompletedTodos',
|
|
createTask: 'tasks:create',
|
|
}),
|
|
async taskSorted (data) {
|
|
const filteredList = this.taskList;
|
|
const taskToMove = filteredList[data.oldIndex];
|
|
const taskIdToMove = taskToMove._id;
|
|
let originTasks = this.getUnfilteredTaskList(this.type);
|
|
if (this.taskListOverride) originTasks = this.taskListOverride;
|
|
|
|
// Server
|
|
const taskIdToReplace = filteredList[data.newIndex];
|
|
const newIndexOnServer = originTasks.findIndex(taskId => taskId === taskIdToReplace);
|
|
|
|
let newOrder;
|
|
if (taskToMove.group.id) {
|
|
newOrder = await this.$store.dispatch('tasks:moveGroupTask', {
|
|
taskId: taskIdToMove,
|
|
position: newIndexOnServer,
|
|
});
|
|
} else {
|
|
newOrder = await this.$store.dispatch('tasks:move', {
|
|
taskId: taskIdToMove,
|
|
position: newIndexOnServer,
|
|
});
|
|
}
|
|
if (!this.taskListOverride) this.user.tasksOrder[`${this.type}s`] = newOrder;
|
|
|
|
// Client
|
|
const deleted = originTasks.splice(data.oldIndex, 1);
|
|
originTasks.splice(data.newIndex, 0, deleted[0]);
|
|
},
|
|
async moveTo (task, where) { // where is 'top' or 'bottom'
|
|
const taskIdToMove = task._id;
|
|
const list = this.getUnfilteredTaskList(this.type);
|
|
|
|
const oldPosition = list.findIndex(t => t._id === taskIdToMove);
|
|
const moved = list.splice(oldPosition, 1);
|
|
const newPosition = where === 'top' ? 0 : list.length;
|
|
list.splice(newPosition, 0, moved[0]);
|
|
|
|
let newOrder = await this.$store.dispatch('tasks:move', {
|
|
taskId: taskIdToMove,
|
|
position: newPosition,
|
|
});
|
|
this.user.tasksOrder[`${this.type}s`] = newOrder;
|
|
},
|
|
async rewardSorted (data) {
|
|
const rewardsList = this.inAppRewards;
|
|
const rewardToMove = rewardsList[data.oldIndex];
|
|
|
|
let newOrder = await this.$store.dispatch('user:movePinnedItem', {
|
|
path: rewardToMove.path,
|
|
position: data.newIndex,
|
|
});
|
|
this.user.pinnedItemsOrder = newOrder;
|
|
},
|
|
rewardDragStart () {
|
|
// We need to stop popovers from interfering with our dragging
|
|
this.showPopovers = false;
|
|
},
|
|
rewardDragEnd () {
|
|
this.showPopovers = true;
|
|
},
|
|
quickAdd (ev) {
|
|
// Add a new line if Shift+Enter Pressed
|
|
if (ev.shiftKey) {
|
|
this.quickAddRows++;
|
|
return true;
|
|
}
|
|
|
|
// Do not add new line is added if only Enter is pressed
|
|
ev.preventDefault();
|
|
const text = this.quickAddText;
|
|
if (!text) return false;
|
|
|
|
const tasks = text.split('\n').reverse().filter(taskText => {
|
|
return taskText ? true : false;
|
|
}).map(taskText => {
|
|
const task = taskDefaults({type: this.type, text: taskText});
|
|
task.tags = this.selectedTags;
|
|
return task;
|
|
});
|
|
|
|
this.quickAddText = '';
|
|
this.quickAddRows = 1;
|
|
this.createTask(tasks);
|
|
},
|
|
editTask (task) {
|
|
this.$emit('editTask', task);
|
|
},
|
|
activateFilter (type, filter = '') {
|
|
// Needs a separate API call as this data may not reside in store
|
|
if (type === 'todo' && filter === 'complete2') {
|
|
this.loadCompletedTodos();
|
|
}
|
|
|
|
// the only time activateFilter is called with filter==='' is when the component is first created
|
|
// this can be used to check If the user has set 'due' as default filter for daily
|
|
// and set the filter as 'due' only when the component first loads and not on subsequent reloads.
|
|
if (type === 'daily' && filter === '' && this.user.preferences.dailyDueDefaultView) {
|
|
filter = 'due';
|
|
}
|
|
|
|
this.activeFilter = getActiveFilter(type, filter);
|
|
},
|
|
setColumnBackgroundVisibility () {
|
|
this.$nextTick(() => {
|
|
if (!this.$refs.columnBackground) return;
|
|
|
|
const tasksWrapperEl = this.$refs.tasksWrapper;
|
|
|
|
const tasksWrapperHeight = tasksWrapperEl.offsetHeight;
|
|
const quickAddHeight = this.$refs.quickAdd ? this.$refs.quickAdd.offsetHeight : 0;
|
|
const tasksListHeight = this.$refs.tasksList.$el.offsetHeight;
|
|
|
|
let combinedTasksHeights = tasksListHeight + quickAddHeight;
|
|
|
|
const rewardsList = tasksWrapperEl.getElementsByClassName('reward-items')[0];
|
|
if (rewardsList) {
|
|
combinedTasksHeights += rewardsList.offsetHeight;
|
|
}
|
|
|
|
const columnBackgroundStyle = this.$refs.columnBackground.style;
|
|
|
|
if (tasksWrapperHeight - combinedTasksHeights < 150) {
|
|
columnBackgroundStyle.display = 'none';
|
|
} else {
|
|
columnBackgroundStyle.display = 'block';
|
|
}
|
|
});
|
|
},
|
|
filterByTagList (taskList, tagList = []) {
|
|
let filteredTaskList = taskList;
|
|
// filter requested tasks by tags
|
|
if (!isEmpty(tagList)) {
|
|
filteredTaskList = taskList.filter(
|
|
task => tagList.every(tag => task.tags.indexOf(tag) !== -1)
|
|
);
|
|
}
|
|
return filteredTaskList;
|
|
},
|
|
filterBySearchText (taskList, searchText = '') {
|
|
let filteredTaskList = taskList;
|
|
// filter requested tasks by search text
|
|
if (searchText) {
|
|
// to ensure broadest case insensitive search matching
|
|
let searchTextLowerCase = searchText.toLowerCase();
|
|
filteredTaskList = taskList.filter(
|
|
task => {
|
|
// eslint rule disabled for block to allow nested binary expression
|
|
/* eslint-disable no-extra-parens */
|
|
return (
|
|
task.text.toLowerCase().indexOf(searchTextLowerCase) > -1 ||
|
|
(task.notes && task.notes.toLowerCase().indexOf(searchTextLowerCase) > -1) ||
|
|
(task.checklist && task.checklist.length > 0 &&
|
|
task.checklist.some(checkItem => checkItem.text.toLowerCase().indexOf(searchTextLowerCase) > -1))
|
|
);
|
|
/* eslint-enable no-extra-parens */
|
|
});
|
|
}
|
|
return filteredTaskList;
|
|
},
|
|
openBuyDialog (rewardItem) {
|
|
if (rewardItem.locked) return;
|
|
|
|
// Buy armoire and health potions immediately
|
|
let itemsToPurchaseImmediately = ['potion', 'armoire'];
|
|
if (itemsToPurchaseImmediately.indexOf(rewardItem.key) !== -1) {
|
|
this.makeGenericPurchase(rewardItem);
|
|
this.$emit('buyPressed', rewardItem);
|
|
return;
|
|
}
|
|
|
|
if (rewardItem.purchaseType === 'quests') {
|
|
this.selectedItemToBuy = rewardItem;
|
|
this.$root.$emit('bv::show::modal', 'buy-quest-modal');
|
|
return;
|
|
}
|
|
|
|
if (rewardItem.purchaseType !== 'gear' || !rewardItem.locked) {
|
|
this.$emit('openBuyDialog', rewardItem);
|
|
}
|
|
},
|
|
resetItemToBuy ($event) {
|
|
if (!$event) {
|
|
this.selectedItemToBuy = null;
|
|
}
|
|
},
|
|
togglePinned (item) {
|
|
if (!item.pinType) {
|
|
this.error(this.$t('errorTemporaryItem'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (!this.$store.dispatch('user:togglePinnedItem', {type: item.pinType, path: item.path})) {
|
|
this.text(this.$t('unpinnedItem', {item: item.text}));
|
|
}
|
|
} catch (e) {
|
|
this.error(e.message);
|
|
}
|
|
},
|
|
},
|
|
};
|
|
</script>
|