Files
habitica/website/client/src/components/tasks/user.vue
T
Fiz 42083efb7e Emojis Update (#15620)
* Add group plan selection modal for upgrades

Allow users to select an existing group to upgrade before creating a new one.

* crlf -> lf lint

* set selection of group plan

Also tiny UI fixes

* Update group plan selection to include expired plans

* Add includeExpiredPlans option to group fetching

* force flag when fetching group plans

* Update group plan eligibility check

* Fix eslint error in push notification import

* replace chaining (?.) w/null check

* Remove comment

* set initial selected group plan, and fix card rounding

* format member count

* Show warning for pending party invites when upgrading to paid group plan

Show warning for pending party invites when upgrading to paid group plan. If user upgrades from party to group, remove any pending invites

* suppress error toasts for group modal, and UI tweaks for group modal

suppress error toast for 404 on party fetch for users without a party (for group modal), Increase check SVG size in selectableCard, and show "Previously upgraded" label for parties that were canceled group plans

* Clear upgradingGroup state after group plan payment

* Update emoji system to native Unicode rendering

* Fix line endings in habiticaMarkdown test

* fix indented code block detection for markdown-it v14

* update habitica-markdown to include v3 emoji dataset  (pointed towards test branch)

* size emoji in markdown

* emoji autocomplete to chat, messages, tasks, and profile

add :emoji shortcode autocomplete dropdown (reusing existing autocomplete mixin w/new helper)

* try upping github-action fix

* trying another github actions fix

* update habitica-markdown package version (v3.0.0 -> v4.0.0)

* Fix emoji autocomplete overlapping actual text

position dropdown below text

* update group-plans info card styles

* Support Melior emoji autocomplete & more places for emoji autocomplete

Include emoji autocomplete in task checklists, tags, challenge name/summary/description

* position emoji autocomplete dropdown below text area

* fix: replace nested ternary

* Emoji autocomplete fixes

Fix emoji autocomplete overlapping checklist text, and add short name emoji autocomplete

* Have emoji autocomplete dropdown directly below text, add to task tag

* Fix emoji autocomplete starting at beginning/end initially

* lint/line length

* Add group plan selection modal for upgrades

Allow users to select an existing group to upgrade before creating a new one.

* crlf -> lf lint

* set selection of group plan

Also tiny UI fixes

* Update group plan selection to include expired plans

* Add includeExpiredPlans option to group fetching

* force flag when fetching group plans

* Update group plan eligibility check

* Fix eslint error in push notification import

* replace chaining (?.) w/null check

* Remove comment

* set initial selected group plan, and fix card rounding

* format member count

* Show warning for pending party invites when upgrading to paid group plan

Show warning for pending party invites when upgrading to paid group plan. If user upgrades from party to group, remove any pending invites

* suppress error toasts for group modal, and UI tweaks for group modal

suppress error toast for 404 on party fetch for users without a party (for group modal), Increase check SVG size in selectableCard, and show "Previously upgraded" label for parties that were canceled group plans

* Clear upgradingGroup state after group plan payment

* Update emoji system to native Unicode rendering

* Fix line endings in habiticaMarkdown test

* fix indented code block detection for markdown-it v14

* update habitica-markdown to include v3 emoji dataset  (pointed towards test branch)

* size emoji in markdown

* emoji autocomplete to chat, messages, tasks, and profile

add :emoji shortcode autocomplete dropdown (reusing existing autocomplete mixin w/new helper)

* try upping github-action fix

* trying another github actions fix

* update habitica-markdown package version (v3.0.0 -> v4.0.0)

* Fix emoji autocomplete overlapping actual text

position dropdown below text

* update group-plans info card styles

* Support Melior emoji autocomplete & more places for emoji autocomplete

Include emoji autocomplete in task checklists, tags, challenge name/summary/description

* position emoji autocomplete dropdown below text area

* fix: replace nested ternary

* Emoji autocomplete fixes

Fix emoji autocomplete overlapping checklist text, and add short name emoji autocomplete

* Have emoji autocomplete dropdown directly below text, add to task tag

* Fix emoji autocomplete starting at beginning/end initially

* lint/line length

* Revert "trying another github actions fix"

This reverts commit 72fc7fc20e.

* Revert "try upping github-action fix"

This reverts commit 70e48a57aa.

* fix(git): revert ci changes

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
2026-02-26 16:36:37 -06:00

692 lines
20 KiB
Vue

<template>
<div
class="row user-tasks-page"
@click="openCreateBtn ? openCreateBtn = false : null"
>
<broken-task-modal />
<task-modal
ref="taskModal"
:task="editingTask || creatingTask"
:purpose="creatingTask !== null ? 'create' : 'edit'"
@cancel="cancelTaskModal()"
/>
<task-summary
ref="taskSummary"
:task="editingTask"
@cancel="cancelTaskModal()"
/>
<div class="col-12">
<div class="row tasks-navigation">
<div class="col-12 col-md-4 offset-md-4">
<div class="d-flex">
<input
v-model="searchText"
class="form-control input-search"
type="text"
:placeholder="$t('search')"
>
<button
class="btn btn-secondary dropdown-toggle ml-2 d-flex align-items-center search-button"
type="button"
:class="{active: selectedTags.length > 0}"
@click="toggleFilterPanel()"
>
<div
class="svg-icon filter-icon mr-2"
v-html="icons.filter"
></div>
<span v-once>{{ $t('tags') }}</span>
</button>
</div>
<div
v-if="isFilterPanelOpen"
class="filter-panel"
@mouseleave="checkMouseOver"
>
<!-- eslint-disable vue/no-use-v-if-with-v-for -->
<div
v-for="tagsType in tagsByType"
v-if="tagsType.tags.length > 0 || tagsType.key === 'tags'"
:key="tagsType.key"
class="tags-category d-flex flex-column"
>
<!-- eslint-enable vue/no-use-v-if-with-v-for -->
<div class="tags-header">
<strong v-once>{{ $t(tagsType.key) }}</strong>
<a
v-if="tagsType.key !== 'groups' && !editingTags"
class="d-block"
@click="editTags(tagsType.key)"
>{{ $t('editTagsText') }}</a>
</div>
<div class="tags-list container">
<div
class="row"
:class="{'no-gutters': !editingTags}"
>
<template v-if="editingTags && tagsType.key !== 'groups'">
<draggable
v-model="tagsSnap[tagsType.key]"
class="row"
>
<div
v-for="(tag, tagIndex) in tagsSnap[tagsType.key]"
:key="tag.id"
class="col-6"
>
<div class="inline-edit-input-group tag-edit-item input-group">
<div
class="svg-icon inline drag"
v-html="icons.drag"
></div>
<input
:ref="'tagInput-' + tagIndex"
v-model="tag.name"
class="tag-edit-input inline-edit-input form-control"
type="text"
@focus="setActiveTag(tagIndex)"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="autoCompleteMixinSelectAutocomplete($event)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
<div
class="input-group-append"
@click="removeTag(tagIndex, tagsType.key)"
>
<div
class="svg-icon destroy-icon"
v-html="icons.destroy"
></div>
</div>
</div>
</div>
<div
v-if="tagsType.key === 'tags'"
class="col-6 dragSpace"
>
<input
ref="newTagInput"
v-model="newTag"
class="new-tag-item edit-tag-item inline-edit-input form-control"
type="text"
:placeholder="$t('newTag')"
@focus="setActiveTag(-1)"
@keydown="autoCompleteMixinUpdateCarretPosition"
@keydown.tab="autoCompleteMixinHandleTab($event)"
@keydown.up="autoCompleteMixinSelectPreviousAutocomplete($event)"
@keydown.down="autoCompleteMixinSelectNextAutocomplete($event)"
@keypress.enter="newTagEnterHandler($event, tagsType.key)"
@keydown.esc="autoCompleteMixinHandleEscape($event)"
>
</div>
</draggable>
</template>
<template v-if="!editingTags || tagsType.key === 'groups'">
<div
v-for="(tag) in tagsType.tags"
:key="tag.id"
class="col-6"
>
<div class="custom-control custom-checkbox">
<input
:id="`tag-${tag.id}`"
class="custom-control-input"
type="checkbox"
:checked="isTagSelected(tag)"
@change="toggleTag(tag)"
>
<label
v-markdown="tag.name"
class="custom-control-label"
:for="`tag-${tag.id}`"
></label>
</div>
</div>
</template>
</div>
</div>
</div>
<emoji-auto-complete
v-if="editingTags"
ref="emojiAutocomplete"
:text="activeTagText"
:textbox="textbox"
:coords="mixinData.autoComplete.coords"
:caret-position="mixinData.autoComplete.caretPosition"
@select="selectedTagAutocomplete"
/>
<div class="filter-panel-footer clearfix">
<template v-if="editingTags === true">
<div class="text-center">
<a
v-once
class="mr-3 btn-filters-primary"
@click="saveTags()"
>{{ $t('saveEdits') }}</a>
<a
v-once
class="btn-filters-secondary"
@click="cancelTagsEditing()"
>{{ $t('cancel') }}</a>
</div>
</template>
<template v-else>
<div class="float-left">
<a
v-once
class="btn-filters-danger"
@click="resetFilters()"
>{{ $t('resetFilters') }}</a>
</div>
<div class="float-right">
<a
v-once
class="btn-filters-secondary"
@click="closeFilterPanel()"
>{{ $t('cancel') }}</a>
</div>
</template>
</div>
</div>
</div>
<div class="create-task-area">
<div
id="create-task-btn"
class="btn btn-primary create-btn d-flex align-items-center"
:class="{open: openCreateBtn}"
tabindex="0"
@click.stop.prevent="openCreateBtn = !openCreateBtn"
@keypress.enter="openCreateBtn = !openCreateBtn"
>
<div
class="svg-icon icon-10 color"
v-html="icons.positive"
></div>
<div class="ml-75 mr-1">
{{ $t('addTask') }}
</div>
</div>
<div
v-if="openCreateBtn"
class="dropdown"
>
<div
v-for="type in columns"
:key="type"
class="dropdown-item d-flex px-2 py-1"
@click="createTask(type)"
>
<div class="d-flex align-items-center justify-content-center task-icon">
<div
class="svg-icon m-auto"
:class="`icon-${type}`"
v-html="icons[type]"
></div>
</div>
<div class="task-label ml-2">
{{ $t(type) }}
</div>
</div>
</div>
</div>
</div>
<div class="row tasks-columns">
<task-column
v-for="column in columns"
:key="column"
class="col-lg-3 col-md-6"
:type="column"
:is-user="true"
:search-text="searchTextThrottled"
:selected-tags="selectedTags"
@editTask="editTask"
@taskSummary="taskSummary"
@openBuyDialog="openBuyDialog($event)"
/>
</div>
</div>
<spells />
</div>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
@import '@/assets/scss/create-task.scss';
.user-tasks-page {
padding-left: 12px;
padding-right: 12px;
padding-top: 16px;
}
.tasks-navigation {
margin-bottom: 20px;
}
.filter-icon {
width: 16px;
height: 16px;
}
.filter-panel {
position: absolute;
padding-left: 24px;
padding-right: 24px;
max-width: 50vw;
min-width: 300px;
width: 100%;
z-index: 9999;
background: $white;
border-radius: 2px;
box-shadow: 0 2px 2px 0 rgba($black, 0.16), 0 1px 4px 0 rgba($black, 0.12);
top: 44px;
left: 20vw;
font-size: 14px;
line-height: 1.43;
text-overflow: ellipsis;
.tags-category {
border-bottom: 1px solid $gray-600;
padding-bottom: 24px;
padding-top: 24px;
}
.tags-header {
flex-basis: 60px;
flex-shrink: 0;
margin-left: 10px;
a {
font-size: 12px;
line-height: 1.33;
margin-top: 4px;
&:focus, &:hover, &:active {
text-decoration: underline;
}
}
}
.tag-edit-input {
border-bottom: 1px solid $gray-500 !important;
&:focus, &:focus ~ .input-group-append {
border-color: $purple-500 !important;
}
}
.new-tag-item {
width: 100%;
background-repeat: no-repeat;
background-position: center left 10px;
border-bottom: 1px solid $gray-500 !important;
background-size: 10px 10px;
padding-left: 40px;
background-image: url(@/assets/svg/for-css/positive.svg);
}
.tag-edit-item {
.input-group-append {
background: $white;
border-bottom: 1px solid $gray-500 !important;
&:focus {
border-color: $purple-500;
}
}
.destroy-icon {
display: none;
}
&:hover .destroy-icon {
display: inline;
}
}
.custom-control-label {
margin-left: 10px;
word-break: break-word;
}
.filter-panel-footer {
padding-top: 16px;
padding-bottom: 16px;
a {
&:focus, &:hover, &:active {
text-decoration: underline;
}
}
.btn-filters-danger {
color: $red-50;
}
.btn-filters-primary {
color: $blue-10;
}
.btn-filters-secondary {
color: $gray-300;
}
}
}
.create-task-area {
top: 1px;
}
.drag {
cursor: grab;
margin: auto 0;
height: 20px;
width: 20px;
color: #C3C0C7;
&:hover {
color: #878190;
}
}
.dragSpace {
padding-left: 32px;
}
@media only screen and (max-width: 768px) {
.filter-panel {
max-width: none;
left: 0px;
}
}
</style>
<script>
import { v4 as uuid } from 'uuid';
import Vue from 'vue';
import throttle from 'lodash/throttle';
import cloneDeep from 'lodash/cloneDeep';
import draggable from 'vuedraggable';
import taskDefaults from '@/../../common/script/libs/taskDefaults';
import TaskColumn from './column';
import TaskModal from './taskModal';
import TaskSummary from './taskSummary';
import spells from './spells';
import markdown from '@/directives/markdown';
import positiveIcon from '@/assets/svg/positive.svg?raw';
import filterIcon from '@/assets/svg/filter.svg?raw';
import deleteIcon from '@/assets/svg/delete.svg?raw';
import habitIcon from '@/assets/svg/habit.svg?raw';
import dailyIcon from '@/assets/svg/daily.svg?raw';
import todoIcon from '@/assets/svg/todo.svg?raw';
import rewardIcon from '@/assets/svg/reward.svg?raw';
import dragIcon from '@/assets/svg/drag_indicator.svg?raw';
import { mapState, mapActions } from '@/libs/store';
import brokenTaskModal from './brokenTaskModal';
import emojiAutoComplete from '@/components/chat/emojiAutoComplete';
import { autoCompleteHelperMixin } from '@/mixins/autoCompleteHelper';
export default {
components: {
TaskColumn,
TaskModal,
TaskSummary,
spells,
brokenTaskModal,
draggable,
emojiAutoComplete,
},
directives: {
markdown,
},
mixins: [autoCompleteHelperMixin],
data () {
return {
columns: ['habit', 'daily', 'todo', 'reward'],
searchText: null,
searchTextThrottled: null,
isFilterPanelOpen: false,
openCreateBtn: false,
icons: Object.freeze({
positive: positiveIcon,
filter: filterIcon,
destroy: deleteIcon,
habit: habitIcon,
daily: dailyIcon,
todo: todoIcon,
reward: rewardIcon,
drag: dragIcon,
}),
selectedTags: [],
temporarilySelectedTags: [],
tagsSnap: {
tags: [],
challenges: [],
}, // tags snapshot when being edited
editingTags: false,
newTag: null,
editingTask: null,
creatingTask: null,
textbox: null,
activeTagIndex: -1,
};
},
computed: {
...mapState({ user: 'user.data' }),
activeTagText () {
if (this.activeTagIndex === -1) {
return this.newTag || '';
}
const tag = this.tagsSnap.tags[this.activeTagIndex];
return tag ? tag.name || '' : '';
},
tagsByType () {
const userTags = this.user.tags;
const tagsByType = {
challenges: {
key: 'challenges',
tags: [],
},
groups: {
key: 'groups',
tags: [],
},
user: {
key: 'tags',
tags: [],
},
};
userTags.forEach(t => {
if (t.group) {
tagsByType.groups.tags.push(t);
} else if (t.challenge) {
tagsByType.challenges.tags.push(t);
} else {
tagsByType.user.tags.push(t);
}
});
return tagsByType;
},
},
watch: {
searchText: throttle(function throttleSearch () {
this.searchTextThrottled = this.searchText.toLowerCase();
}, 250),
},
mounted () {
this.$store.dispatch('common:setTitle', {
section: this.$t('tasks'),
});
if (this.$store.state.postLoadModal) {
const modalToLoad = this.$store.state.postLoadModal;
if (modalToLoad.includes('profile')) {
this.$router.push(modalToLoad);
} else {
this.$root.$emit('bv::show::modal', modalToLoad);
}
this.$store.state.postLoadModal = '';
}
},
methods: {
...mapActions({ setUser: 'user:set' }),
checkMouseOver: throttle(function throttleSearch () {
if (this.editingTags) return;
this.closeFilterPanel();
}, 250),
editTags () {
// clone the arrays being edited so that we can revert if needed
this.tagsSnap.tags = this.tagsByType.user.tags.slice();
this.tagsSnap.challenges = this.tagsByType.challenges.tags.slice();
this.editingTags = true;
},
addTag (eventObj, key) {
this.tagsSnap[key].push({ id: uuid(), name: this.newTag });
this.newTag = null;
},
setActiveTag (index) {
this.activeTagIndex = index;
if (index === -1) {
const refArr = this.$refs.newTagInput;
this.textbox = Array.isArray(refArr) ? refArr[0] : refArr;
} else {
const refArr = this.$refs[`tagInput-${index}`];
if (!refArr) {
this.textbox = null;
} else {
this.textbox = Array.isArray(refArr) ? refArr[0] : refArr;
}
}
},
newTagEnterHandler (e, key) {
const ac = this._getActiveAutocomplete();
if (ac && ac.selected !== null) {
e.preventDefault();
ac.makeSelection();
} else {
if (ac) ac.cancel();
this.addTag(e, key);
}
},
selectedTagAutocomplete (newText, newCaret) {
if (this.activeTagIndex === -1) {
this.newTag = newText;
} else {
this.tagsSnap.tags[this.activeTagIndex].name = newText;
}
this.$nextTick(() => {
if (this.textbox) {
this.textbox.setSelectionRange(newCaret, newCaret);
this.textbox.focus();
}
});
},
removeTag (index, key) {
const tagId = this.tagsSnap[key][index].id;
const indexInSelected = this.selectedTags.indexOf(tagId);
const indexInTempSelected = this.temporarilySelectedTags.indexOf(tagId);
if (indexInSelected !== -1) {
this.$delete(this.selectedTags, indexInSelected);
}
if (indexInTempSelected !== -1) {
this.$delete(this.temporarilySelectedTags, indexInTempSelected);
}
this.$delete(this.tagsSnap[key], index);
},
saveTags () {
if (this.newTag) this.addTag(null, 'tags');
this.tagsByType.user.tags = this.tagsSnap.tags;
this.tagsByType.challenges.tags = this.tagsSnap.challenges;
this.setUser({ tags: this.tagsSnap.tags.concat(this.tagsSnap.challenges) });
this.cancelTagsEditing();
},
cancelTagsEditing () {
this.editingTags = false;
this.tagsSnap = {
tags: [],
challenges: [],
};
this.newTag = null;
},
// Need Vue.nextTick() otherwise the first time the modal is not rendered
editTask (task) {
this.editingTask = cloneDeep(task);
Vue.nextTick(() => {
this.$root.$emit('bv::show::modal', 'task-modal');
});
},
taskSummary (task) {
this.editingTask = cloneDeep(task);
Vue.nextTick(() => {
this.$root.$emit('bv::show::modal', 'task-summary');
});
},
createTask (type) {
this.openCreateBtn = false;
this.creatingTask = taskDefaults({ type, text: '' }, this.user);
this.creatingTask.tags = this.selectedTags.slice();
// Necessary otherwise the first time the modal is not rendered
Vue.nextTick(() => {
this.$root.$emit('bv::show::modal', 'task-modal');
});
},
cancelTaskModal () {
this.editingTask = null;
this.creatingTask = null;
},
toggleFilterPanel () {
if (this.isFilterPanelOpen === true) {
this.closeFilterPanel();
} else {
this.openFilterPanel();
}
},
openFilterPanel () {
this.isFilterPanelOpen = true;
this.temporarilySelectedTags = this.selectedTags.slice();
},
closeFilterPanel () {
this.temporarilySelectedTags = [];
this.isFilterPanelOpen = false;
},
resetFilters () {
this.selectedTags = [];
this.closeFilterPanel();
},
applyFilters () {
const { temporarilySelectedTags } = this;
this.selectedTags = temporarilySelectedTags.slice();
},
toggleTag (tag) {
const { temporarilySelectedTags } = this;
const tagI = temporarilySelectedTags.indexOf(tag.id);
if (tagI === -1) {
temporarilySelectedTags.push(tag.id);
} else {
temporarilySelectedTags.splice(tagI, 1);
}
this.applyFilters();
},
isTagSelected (tag) {
const tagId = tag.id;
if (this.temporarilySelectedTags.indexOf(tagId) !== -1) return true;
return false;
},
openBuyDialog (item) {
this.$root.$emit('buyModal::showItem', item);
},
},
};
</script>