Files
habitica/website/client/src/components/challenges/challengeDetail.vue
Fiz e8eeb76cab Fix End challenge search to load participant based on search query (#15520)
* Load all participants when end challenge modal is opened.

* Fetch members in batches until members are loaded

* Fix challenge winner search to load all participants

Separated loading flags to prevent conflicts between modals

* Rename end challenge members flag to be more clear

* await load members

* Implement challenge member search only when searching w/debounce
2025-09-30 16:33:36 -05:00

736 lines
20 KiB
Vue

<template>
<div class="row">
<report-challenge-modal />
<challenge-modal @updatedChallenge="updatedChallenge" />
<leave-challenge-modal
:challenge-id="challenge._id"
@update-challenge="updateChallenge"
/>
<close-challenge-modal
:challenge-id="challenge._id"
:prize="challenge.prize"
:flag-count="challenge.flagCount"
/>
<challenge-member-progress-modal :challenge-id="challenge._id" />
<div class="col-12 col-md-8 standard-page">
<div class="row">
<div class="col-12 col-md-6">
<div
v-if="canViewFlags"
class="flagged"
>
<div
v-if="flaggedNotHidden"
>
{{ $t("flaggedNotHidden") }}
</div>
<div
v-else-if="flaggedAndHidden"
>
{{ $t("flaggedAndHidden") }}
</div>
</div>
<h1 v-markdown="challenge.name"></h1>
<div>
<span class="mr-1 ml-0 d-block">
<strong v-once>{{ $t('createdBy') }}:</strong>
<span v-if="challenge.leader === null">
{{ $t('noChallengeOwner') }}
</span>
<user-link
v-else
class="mx-1"
:user="challenge.leader"
/>
</span>
<span
v-if="challenge.group && challenge.group.name !== 'Tavern'"
class="mr-1 ml-0 d-block"
>
<strong v-once>{{ $t(challenge.group.type) }}:</strong>
<group-link
class="mx-1"
:group="challenge.group"
/>
</span>
<!-- @TODO: make challenge.author a variable inside the
createdBy string (helps with RTL languages)-->
<!-- @TODO: Implement in V2 strong.margin-left
(v-once).svg-icon.calendar-icon(v-html="icons.calendarIcon")
{{$t('endDate')}}
// "endDate": "End Date: <% endDate %>",-->
<!-- span {{challenge.endDate}}-->
</div>
<div class="tags">
<span
v-for="tag in challenge.tags"
:key="tag"
class="tag"
>{{ tag }}</span>
</div>
</div>
<div class="col-12 col-md-6 text-right">
<div
class="box member-count p-2"
@click="showMemberModal()"
>
<div class="box-content">
<div class="icon-number-row">
<div
class="svg-icon member-icon"
v-html="icons.memberIcon"
></div>
<span class="number">{{ challenge.memberCount }}</span>
</div>
<div
v-once
class="details"
>
{{ $t('participantsTitle') }}
</div>
</div>
</div>
<div class="box prize-count p-2">
<div class="box-content">
<div class="icon-number-row">
<div
class="svg-icon gem-icon"
v-html="icons.gemIcon"
></div>
<span class="number">{{ challenge.prize || 0 }}</span>
</div>
<div
v-once
class="details"
>
{{ $t('prize') }}
</div>
</div>
</div>
</div>
</div>
<div class="row challenge-actions">
<div class="col-12 col-md-6">
<strong class="view-progress">{{ $t('viewProgressOf') }}</strong>
<member-search-dropdown
:text="$t('selectParticipant')"
:members="members"
:challenge-id="challengeId"
@member-selected="openMemberProgressModal"
@opened="initialMembersLoad()"
/>
</div>
<div class="col-12 col-md-6 text-right">
<span v-if="isLeader || isAdmin">
<b-dropdown
class="create-dropdown select-list"
:text="$t('addTask')"
:variant="'success'"
>
<b-dropdown-item
v-for="type in columns"
:key="type"
class="selectListItem"
@click="createTask(type)"
>{{ $t(type) }}</b-dropdown-item>
</b-dropdown>
<task-modal
ref="taskModal"
:task="workingTask"
:purpose="taskFormPurpose"
:challenge-id="challengeId"
@cancel="cancelTaskModal()"
@taskCreated="taskCreated"
@taskEdited="taskEdited"
@taskDestroyed="taskDestroyed"
/>
</span>
</div>
</div>
<div class="row">
<!-- eslint-disable vue/no-use-v-if-with-v-for -->
<task-column
v-for="column in columns"
v-if="tasksByType[column].length > 0"
:key="column"
class="col-12 col-sm-6"
:type="column"
:task-list-override="tasksByType[column]"
:challenge="challenge"
:draggable-override="isLeader || isAdmin"
@editTask="editTask"
@taskDestroyed="taskDestroyed"
/>
<!-- eslint-enable vue/no-use-v-if-with-v-for -->
</div>
</div>
<div class="col-12 col-md-4 sidebar standard-page">
<div
v-if="canJoin"
class="button-container"
>
<button
v-once
class="btn btn-success"
@click="joinChallenge()"
>
{{ $t('joinChallenge') }}
</button>
</div>
<div
v-if="isLeader || isAdmin"
class="button-container"
>
<button
v-once
class="btn btn-primary"
@click="edit()"
>
{{ $t('editChallenge') }}
</button>
</div>
<div
v-if="isLeader || isAdmin"
class="button-container"
>
<div>
<button
class="btn"
:disabled="flaggedAndHidden || chatRevocation"
:class="flaggedAndHidden || chatRevocation
? 'disabled btn-disabled' : 'btn-primary'"
@click="cloneChallenge()"
>
{{ $t('clone') }}
</button>
</div>
</div>
<div
v-if="isLeader || isAdmin"
class="button-container"
>
<button
v-once
class="btn btn-primary"
@click="exportChallengeCsv()"
>
{{ $t('exportChallengeCsv') }}
</button>
</div>
<div
v-if="isLeader || isAdmin"
class="button-container"
>
<button
v-once
class="btn btn-danger"
@click="closeChallenge()"
>
{{ $t('endChallenge') }}
</button>
</div>
<div
v-if="!isOfficial"
class="button-container"
>
<button
v-once
class="btn btn-danger"
@click="reportChallenge()"
>
{{ $t('report') }}
</button>
</div>
<div>
<sidebar-section :title="$t('challengeSummary')">
<p v-markdown="challenge.summary"></p>
</sidebar-section>
<sidebar-section :title="$t('challengeDescription')">
<p v-markdown="challenge.description"></p>
</sidebar-section>
</div>
<div
v-if="isMember"
class="text-center"
>
<button
v-once
class="btn btn-danger"
@click="leaveChallenge()"
>
{{ $t('leaveChallenge') }}
</button>
</div>
</div>
</div>
</template>
<style lang='scss' scoped>
@import '@/assets/scss/colors.scss';
h1 {
color: $purple-200;
margin-bottom: 8px;
}
.margin-left {
margin-left: 1em;
}
span {
margin-left: .5em;
}
.button-container {
margin-bottom: 1em;
button {
width: 100%;
}
}
.calendar-icon {
width: 12px;
display: inline-block;
margin-right: .2em;
}
.tags {
margin-top: 1em;
}
.tag {
border-radius: 30px;
background-color: $gray-600;
padding: .5em;
}
.sidebar {
background-color: $gray-600;
}
.box {
display: inline-block;
border-radius: 2px;
background-color: $white;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
margin-left: 1em;
width: 120px;
height: 76px;
text-align: center;
font-size: 20px;
vertical-align: bottom;
overflow: hidden;
position: relative;
&.member-count:hover {
cursor: pointer;
}
.box-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
.icon-number-row {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.1em;
.number {
font-size: 20px;
font-weight: normal;
margin-left: 0.2em;
}
}
.svg-icon {
width: 30px;
display: inline-block;
vertical-align: bottom;
}
.details {
font-size: 12px;
color: $gray-200;
width: 100%;
padding: 0 4px;
line-height: 1.15;
word-break: break-word;
max-height: 2.3em;
overflow: visible;
}
&.member-count {
.icon-number-row {
.svg-icon {
width: 24px;
height: 24px;
}
.number {
font-size: 18px;
}
}
.details {
font-size: 11px;
line-height: 1.1;
max-height: 2.2em;
}
}
&.prize-count {
.icon-number-row {
.svg-icon {
width: 24px;
height: 24px;
}
.number {
font-size: 18px;
}
}
.details {
font-size: 11px;
line-height: 1.1;
max-height: 2.2em;
}
}
}
.description-section {
margin-top: 2em;
}
.challenge-actions {
margin-top: 1em;
margin-bottom: 2em;
.view-progress {
margin-right: .5em;
}
}
.flagged {
margin-left: 0em;
color: $red-10;
span {
margin-left: 0em;
}
}
</style>
<script>
import Vue from 'vue';
import findIndex from 'lodash/findIndex';
import cloneDeep from 'lodash/cloneDeep';
import omit from 'lodash/omit';
import { v4 as uuid } from 'uuid';
import taskDefaults from '@/../../common/script/libs/taskDefaults';
import { userStateMixin } from '../../mixins/userState';
import externalLinks from '../../mixins/externalLinks';
import memberSearchDropdown from '@/components/members/memberSearchDropdown';
import closeChallengeModal from './closeChallengeModal';
import Column from '../tasks/column';
import TaskModal from '../tasks/taskModal';
import markdownDirective from '@/directives/markdown';
import challengeModal from './challengeModal';
import challengeMemberProgressModal from './challengeMemberProgressModal';
import challengeMemberSearchMixin from '@/mixins/challengeMemberSearch';
import leaveChallengeModal from './leaveChallengeModal';
import reportChallengeModal from './reportChallengeModal';
import sidebarSection from '../sidebarSection';
import userLink from '../userLink';
import groupLink from '../groupLink';
import gemIcon from '@/assets/svg/gem.svg?raw';
import memberIcon from '@/assets/svg/member-icon.svg?raw';
import calendarIcon from '@/assets/svg/calendar.svg?raw';
const TASK_KEYS_TO_REMOVE = ['_id', 'completed', 'date', 'dateCompleted', 'history', 'id', 'streak', 'createdAt', 'challenge'];
export default {
directives: {
markdown: markdownDirective,
},
components: {
closeChallengeModal,
leaveChallengeModal,
reportChallengeModal,
challengeModal,
challengeMemberProgressModal,
memberSearchDropdown,
sidebarSection,
TaskColumn: Column,
TaskModal,
userLink,
groupLink,
},
mixins: [challengeMemberSearchMixin, externalLinks, userStateMixin],
async beforeRouteUpdate (to, from, next) {
this.searchId = to.params.challengeId;
await this.loadChallenge();
next();
},
props: ['challengeId'],
data () {
return {
searchId: '',
columns: ['habit', 'daily', 'todo', 'reward'],
icons: Object.freeze({
gemIcon,
memberIcon,
calendarIcon,
}),
challenge: {},
members: [],
membersLoaded: false,
tasksByType: {
habit: [],
daily: [],
todo: [],
reward: [],
},
editingTask: {},
creatingTask: {},
workingTask: {},
taskFormPurpose: 'create',
searchTerm: '',
memberResults: [],
isOfficial: true,
};
},
computed: {
isMember () {
return this.user.challenges.indexOf(this.challenge._id) !== -1;
},
isLeader () {
if (!this.challenge.leader) return false;
return this.user._id === this.challenge.leader._id;
},
isAdmin () {
return this.hasPermission(this.user, 'challengeAdmin');
},
canJoin () {
return !this.isMember;
},
// canViewFlags should allow only moderators/admins to see flags
canViewFlags () {
const isModerator = this.hasPermission(this.user, 'moderator');
if (isModerator && this.challenge.flagCount > 0) return true;
return false;
},
// flaggedNotHidden should allow mods/admins & challenge owner to see flags
flaggedNotHidden () {
return this.challenge.flagCount === 1;
},
// flaggedAndHidden should only allow admin to see challenge & flags
flaggedAndHidden () {
return this.challenge.flagCount > 1;
},
chatRevocation () {
return this.user.flags.chatRevoked
&& this.challenge.group && this.challenge.group.name === 'Tavern';
},
},
watch: {
'challenge.name': {
handler (newVal) {
this.$store.dispatch('common:setTitle', {
section: this.$t('challenge'),
subSection: newVal.name,
});
},
},
},
async mounted () {
if (!this.searchId) this.searchId = this.challengeId;
if (!this.challenge._id) await this.loadChallenge();
this.isOfficial = this.challenge.official
|| this.challenge.categories?.some(category => category.name === 'habitica_official');
this.handleExternalLinks();
},
updated () {
this.handleExternalLinks();
},
methods: {
cleanUpTask (task) {
const cleansedTask = omit(task, TASK_KEYS_TO_REMOVE);
// Copy checklists but reset to uncomplete and assign new id
if (!cleansedTask.checklist) cleansedTask.checklist = [];
cleansedTask.checklist.forEach(item => {
item.completed = false;
item.id = uuid();
});
if (cleansedTask.type !== 'reward') {
delete cleansedTask.value;
}
return cleansedTask;
},
async loadChallenge () {
try {
this.challenge = await this.$store.dispatch('challenges:getChallenge', { challengeId: this.searchId });
} catch (e) {
this.$router.push('/challenges/findChallenges');
return;
}
this.$store.dispatch('common:setTitle', {
subSection: this.challenge.name,
section: this.$t('challenges'),
});
const tasks = await this.$store.dispatch('tasks:getChallengeTasks', { challengeId: this.searchId });
this.tasksByType = {
habit: [],
daily: [],
todo: [],
reward: [],
};
tasks.forEach(task => {
this.tasksByType[task.type].push(task);
});
},
/**
* Method for loading members of a group, with optional parameters for
* modifying requests.
*
* @param {Object} payload Used for modifying requests for members
*/
loadMembers (payload = null) {
// Remove unnecessary data
if (payload && payload.groupId) {
delete payload.groupId;
}
return this.$store.dispatch('members:getChallengeMembers', payload);
},
initialMembersLoad () {
this.$store.state.memberModalOptions.loading = true;
if (!this.membersLoaded) {
this.membersLoaded = true;
this.loadMembers({
challengeId: this.searchId,
includeAllPublicFields: true,
}).then(m => {
this.members.push(...m);
this.$store.state.memberModalOptions.loading = false;
});
} else {
this.$store.state.memberModalOptions.loading = false;
}
},
editTask (task) {
this.taskFormPurpose = 'edit';
this.editingTask = cloneDeep(task);
this.workingTask = this.editingTask;
// Necessary otherwise the first time the modal is not rendered
Vue.nextTick(() => {
this.$root.$emit('bv::show::modal', 'task-modal');
});
},
createTask (type) {
this.taskFormPurpose = 'create';
this.creatingTask = taskDefaults({ type, text: '' }, this.user);
this.workingTask = this.creatingTask;
// 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;
},
taskCreated (task) {
this.tasksByType[task.type].unshift(task);
},
taskEdited (task) {
const index = findIndex(this.tasksByType[task.type], taskItem => taskItem._id === task._id);
this.tasksByType[task.type].splice(index, 1, task);
},
taskDestroyed (task) {
const index = findIndex(this.tasksByType[task.type], taskItem => taskItem._id === task._id);
this.tasksByType[task.type].splice(index, 1);
},
showMemberModal () {
this.initialMembersLoad();
this.$root.$emit('habitica:show-member-modal', {
challengeId: this.challenge._id,
groupId: 'challenge', // @TODO: change these terrible settings
group: this.challenge.group,
memberCount: this.challenge.memberCount,
viewingMembers: this.members,
fetchMoreMembers: this.loadMembers,
});
},
async joinChallenge () {
this.user.challenges.push(this.searchId);
this.challenge = await this.$store.dispatch('challenges:joinChallenge', { challengeId: this.searchId });
this.membersLoaded = false;
this.members = [];
await Promise.all([
this.$store.dispatch('user:fetch', { forceLoad: true }),
this.$store.dispatch('tasks:fetchUserTasks', { forceLoad: true }),
]);
},
async leaveChallenge () {
this.$root.$emit('bv::show::modal', 'leave-challenge-modal');
},
async updateChallenge () {
this.challenge = await this.$store.dispatch('challenges:getChallenge', { challengeId: this.searchId });
this.membersLoaded = false;
this.members = [];
},
closeChallenge () {
this.$root.$emit('bv::show::modal', 'close-challenge-modal');
},
edit () {
this.$root.$emit('habitica:update-challenge', {
challenge: this.challenge,
});
},
// @TODO: view members
updatedChallenge (eventData) {
Object.assign(this.challenge, eventData.challenge);
},
openMemberProgressModal (member) {
this.$root.$emit('habitica:challenge:member-progress', {
progressMemberId: member._id,
isLeader: this.isLeader,
isAdmin: this.isAdmin,
});
},
async exportChallengeCsv () {
window.location = `/api/v4/challenges/${this.searchId}/export/csv`;
},
cloneChallenge () {
this.$root.$emit('habitica:clone-challenge', {
challenge: this.challenge,
});
},
reportChallenge () {
this.$root.$emit('habitica::report-challenge', {
challenge: this.challenge,
});
},
async showCannotCloneModal () {
this.$root.$emit('bv::show::modal', 'cannot-clone-modal');
},
},
};
</script>