add summary field to challenges and guilds (#8960)

* create new summary field for challenges

* finish implementating summary for challenges, add some support for guilds

* make small improvements to challenges code

* fix lint errors

* add more code to support summaries for guilds (still more needed)

* fix existing tests by adding summary field

* make existing tests pass

* WIP make "Public Challenges" text translatable

* change "leader" locale key to "guildOrPartyLeader" to make searches for it easier

* remove v-once from h2 headings

* remove failed attempt to localise text in <script>

* add quick-and-dirty error checking for guild not having categories

* make "Public Challenges" text translatable

* rename final ...PlaceHolder strings to ...Placeholder (lower-case "h") for consistency with existing Placeholder strings
This commit is contained in:
Alys
2017-08-23 06:39:45 +10:00
committed by GitHub
parent bd46e3e195
commit 7d0ab1ba25
14 changed files with 199 additions and 92 deletions

View File

@@ -50,6 +50,7 @@ describe('GET /challenges/:challengeId', () => {
_id: group._id, _id: group._id,
id: group.id, id: group.id,
name: group.name, name: group.name,
summary: group.name,
type: group.type, type: group.type,
privacy: group.privacy, privacy: group.privacy,
leader: groupLeader.id, leader: groupLeader.id,
@@ -102,6 +103,7 @@ describe('GET /challenges/:challengeId', () => {
_id: group._id, _id: group._id,
id: group.id, id: group.id,
name: group.name, name: group.name,
summary: group.name,
type: group.type, type: group.type,
privacy: group.privacy, privacy: group.privacy,
leader: groupLeader.id, leader: groupLeader.id,
@@ -154,6 +156,7 @@ describe('GET /challenges/:challengeId', () => {
_id: group._id, _id: group._id,
id: group.id, id: group.id,
name: group.name, name: group.name,
summary: group.name,
type: group.type, type: group.type,
privacy: group.privacy, privacy: group.privacy,
leader: groupLeader.id, leader: groupLeader.id,

View File

@@ -45,6 +45,7 @@ describe('GET challenges/user', () => {
type: publicGuild.type, type: publicGuild.type,
privacy: publicGuild.privacy, privacy: publicGuild.privacy,
name: publicGuild.name, name: publicGuild.name,
summary: publicGuild.name,
leader: publicGuild.leader._id, leader: publicGuild.leader._id,
}); });
}); });
@@ -65,6 +66,7 @@ describe('GET challenges/user', () => {
type: publicGuild.type, type: publicGuild.type,
privacy: publicGuild.privacy, privacy: publicGuild.privacy,
name: publicGuild.name, name: publicGuild.name,
summary: publicGuild.name,
leader: publicGuild.leader._id, leader: publicGuild.leader._id,
}); });
let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
@@ -80,6 +82,7 @@ describe('GET challenges/user', () => {
type: publicGuild.type, type: publicGuild.type,
privacy: publicGuild.privacy, privacy: publicGuild.privacy,
name: publicGuild.name, name: publicGuild.name,
summary: publicGuild.name,
leader: publicGuild.leader._id, leader: publicGuild.leader._id,
}); });
}); });
@@ -100,6 +103,7 @@ describe('GET challenges/user', () => {
type: publicGuild.type, type: publicGuild.type,
privacy: publicGuild.privacy, privacy: publicGuild.privacy,
name: publicGuild.name, name: publicGuild.name,
summary: publicGuild.name,
leader: publicGuild.leader._id, leader: publicGuild.leader._id,
}); });
let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
@@ -115,6 +119,7 @@ describe('GET challenges/user', () => {
type: publicGuild.type, type: publicGuild.type,
privacy: publicGuild.privacy, privacy: publicGuild.privacy,
name: publicGuild.name, name: publicGuild.name,
summary: publicGuild.name,
leader: publicGuild.leader._id, leader: publicGuild.leader._id,
}); });
}); });
@@ -147,6 +152,7 @@ describe('GET challenges/user', () => {
let { group, groupLeader } = await createAndPopulateGroup({ let { group, groupLeader } = await createAndPopulateGroup({
groupDetails: { groupDetails: {
name: 'TestPrivateGuild', name: 'TestPrivateGuild',
summary: 'summary for TestPrivateGuild',
type: 'guild', type: 'guild',
privacy: 'private', privacy: 'private',
}, },
@@ -168,6 +174,7 @@ describe('GET challenges/user', () => {
let { group, groupLeader } = await createAndPopulateGroup({ let { group, groupLeader } = await createAndPopulateGroup({
groupDetails: { groupDetails: {
name: 'TestGuild', name: 'TestGuild',
summary: 'summary for TestGuild',
type: 'guild', type: 'guild',
privacy: 'public', privacy: 'public',
}, },

View File

@@ -56,7 +56,9 @@
div(v-if='isLeader') div(v-if='isLeader')
button.btn.btn-danger(v-once, @click='closeChallenge()') {{$t('endChallenge')}} button.btn.btn-danger(v-once, @click='closeChallenge()') {{$t('endChallenge')}}
.description-section .description-section
h2(v-once) {{$t('challengeDescription')}} h2 {{$t('challengeSummary')}}
p {{challenge.summary}}
h2 {{$t('challengeDescription')}}
p {{challenge.description}} p {{challenge.description}}
</template> </template>

View File

@@ -3,31 +3,30 @@
.form .form
.form-group .form-group
label label
strong(v-once) {{$t('name')}}* strong(v-once) {{$t('name')}} *
b-form-input(type="text", :placeholder="$t('challengeNamePlaceHolder')", v-model="workingChallenge.name") b-form-input(type="text", :placeholder="$t('challengeNamePlaceholder')", v-model="workingChallenge.name")
.form-group .form-group
label label
strong(v-once) {{$t('shortName')}}* strong(v-once) {{$t('shortName')}} *
b-form-input(type="text", :placeholder="$t('shortNamePlaceholder')", v-model="workingChallenge.shortName") b-form-input(type="text", :placeholder="$t('shortNamePlaceholder')", v-model="workingChallenge.shortName")
.form-group .form-group
label label
strong(v-once) {{$t('description')}}* strong(v-once) {{$t('challengeSummary')}} *
div.description-count.float-right {{charactersRemaining}} {{ $t('charactersRemaining') }} div.summary-count {{charactersRemaining}} {{ $t('charactersRemaining') }}
b-form-input.description-textarea(type="text", textarea, :placeholder="$t('challengeDescriptionPlaceHolder')", v-model="workingChallenge.description") b-form-input.summary-textarea(type="text", textarea, :placeholder="$t('challengeSummaryPlaceholder')", v-model="workingChallenge.summary")
.form-group .form-group
label label
strong(v-once) Challenge Information* strong(v-once) {{$t('challengeDescription')}} *
a.float-right {{ $t('markdownFormattingHelp') }} a.float-right {{ $t('markdownFormattingHelp') }}
b-form-input.information-textarea(type="text", textarea, b-form-input.description-textarea(type="text", textarea, :placeholder="$t('challengeDescriptionPlaceholder')", v-model="workingChallenge.description")
:placeholder="$t('challengeInformationPlaceHolder')", v-model="workingChallenge.description")
.form-group(v-if='creating') .form-group(v-if='creating')
label label
strong(v-once) {{$t('where')}} strong(v-once) {{$t('challengeGuild')}} *
select.form-control(v-model='workingChallenge.group') select.form-control(v-model='workingChallenge.group')
option(v-for='group in groups', :value='group._id') {{group.name}} option(v-for='group in groups', :value='group._id') {{group.name}}
.form-group(v-if='workingChallenge.categories') .form-group(v-if='workingChallenge.categories')
label label
strong(v-once) {{$t('categories')}}* strong(v-once) {{$t('categories')}} *
div.category-wrap(@click.prevent="toggleCategorySelect") div.category-wrap(@click.prevent="toggleCategorySelect")
span.category-select(v-if='workingChallenge.categories.length === 0') {{$t('none')}} span.category-select(v-if='workingChallenge.categories.length === 0') {{$t('none')}}
.category-label(v-for='category in workingChallenge.categories') {{$t(categoriesHashByKey[category])}} .category-label(v-for='category in workingChallenge.categories') {{$t(categoriesHashByKey[category])}}
@@ -37,7 +36,7 @@
:key="group.key", :key="group.key",
) )
label.custom-control.custom-checkbox label.custom-control.custom-checkbox
input.custom-control-input(type="checkbox", :value='group.key' v-model="workingChallenge.categories") input.custom-control-input(type="checkbox", :value="group.key" v-model="workingChallenge.categories")
span.custom-control-indicator span.custom-control-indicator
span.custom-control-description(v-once) {{ $t(group.label) }} span.custom-control-description(v-once) {{ $t(group.label) }}
button.btn.btn-primary(@click.prevent="toggleCategorySelect") {{$t('close')}} button.btn.btn-primary(@click.prevent="toggleCategorySelect") {{$t('close')}}
@@ -76,11 +75,19 @@
display: none; display: none;
} }
.description-textarea { .summary-count {
font-size: 12px;
line-height: 1.33;
margin-top: 1em;
color: $gray-200;
text-align: right;
}
.summary-textarea {
height: 90px; height: 90px;
} }
.information-textarea { .description-textarea {
height: 220px; height: 220px;
} }
@@ -123,7 +130,7 @@ import bDropdown from 'bootstrap-vue/lib/components/dropdown';
import bDropdownItem from 'bootstrap-vue/lib/components/dropdown-item'; import bDropdownItem from 'bootstrap-vue/lib/components/dropdown-item';
import bFormInput from 'bootstrap-vue/lib/components/form-input'; import bFormInput from 'bootstrap-vue/lib/components/form-input';
import { TAVERN_ID } from '../../../common/script/constants'; import { TAVERN_ID, MIN_SHORTNAME_SIZE_FOR_CHALLENGES, MAX_SUMMARY_SIZE_FOR_CHALLENGES } from '../../../common/script/constants';
import { mapState } from 'client/libs/store'; import { mapState } from 'client/libs/store';
export default { export default {
@@ -201,7 +208,6 @@ export default {
return { return {
creating: true, creating: true,
charactersRemaining: 250,
workingChallenge: {}, workingChallenge: {},
showCategorySelect: false, showCategorySelect: false,
categoryOptions, categoryOptions,
@@ -228,11 +234,11 @@ export default {
} }
this.groups.push({ this.groups.push({
name: 'Public', name: this.$t('publicChallengesTitle'),
_id: TAVERN_ID, _id: TAVERN_ID,
}); });
this.ressetWorkingChallenge(); this.resetWorkingChallenge();
}, },
watch: { watch: {
user () { user () {
@@ -241,6 +247,10 @@ export default {
}, },
computed: { computed: {
...mapState({user: 'user.data'}), ...mapState({user: 'user.data'}),
charactersRemaining () {
let currentLength = this.workingChallenge.summary ? this.workingChallenge.summary.length : 0;
return MAX_SUMMARY_SIZE_FOR_CHALLENGES - currentLength;
},
maxPrize () { maxPrize () {
let userBalance = this.user.balance || 0; let userBalance = this.user.balance || 0;
userBalance = userBalance * 4; userBalance = userBalance * 4;
@@ -266,11 +276,11 @@ export default {
}, },
}, },
methods: { methods: {
ressetWorkingChallenge () { resetWorkingChallenge () {
this.workingChallenge = { this.workingChallenge = {
name: '', name: '',
summary: '',
description: '', description: '',
information: '',
categories: [], categories: [],
group: '', group: '',
dailys: [], dailys: [],
@@ -285,26 +295,36 @@ export default {
}; };
}, },
async createChallenge () { async createChallenge () {
if (!this.workingChallenge.name) alert('Name is required'); // @TODO: improve error handling, add it to updateChallenge, make errors translatable. Suggestion: `<% fieldName %> is required` where possible, where `fieldName` is inserted as the translatable string that's used for the field header.
if (!this.workingChallenge.description) alert('Description is required'); let errors = '';
if (!this.workingChallenge.name) errors += 'Name is required\n';
if (this.workingChallenge.shortName.length < MIN_SHORTNAME_SIZE_FOR_CHALLENGES) errors += 'Tag name is too short\n';
if (!this.workingChallenge.summary) errors += 'Summary is required\n';
if (this.workingChallenge.summary.length > MAX_SUMMARY_SIZE_FOR_CHALLENGES) errors += 'Summary is too long\n';
if (!this.workingChallenge.description) errors += 'Description is required\n';
if (!this.workingChallenge.group) errors += 'Location of challenge is required ("Add to")\n';
if (!this.workingChallenge.categories || this.workingChallenge.categories.length === 0) errors += 'One or more categories must be selected\n';
if (errors) {
alert(errors);
} else {
this.workingChallenge.timestamp = new Date().getTime();
this.workingChallenge.timestamp = new Date().getTime(); let challenge = await this.$store.dispatch('challenges:createChallenge', {challenge: this.workingChallenge});
// @TODO: When to remove from guild instead?
this.user.balance -= this.workingChallenge.prize / 4;
let challenge = await this.$store.dispatch('challenges:createChallenge', {challenge: this.workingChallenge}); this.$emit('createChallenge', challenge);
// @TODO: When to remove from guild instead? this.resetWorkingChallenge();
this.user.balance -= this.workingChallenge.prize / 4; this.$root.$emit('hide::modal', 'challenge-modal');
this.$router.push(`/challenges/${challenge._id}`);
this.$emit('createChallenge', challenge); }
this.ressetWorkingChallenge();
this.$root.$emit('hide::modal', 'challenge-modal');
this.$router.push(`/challenges/${challenge._id}`);
}, },
updateChallenge () { updateChallenge () {
this.$emit('updatedChallenge', { this.$emit('updatedChallenge', {
challenge: this.workingChallenge, challenge: this.workingChallenge,
}); });
this.$store.dispatch('challenges:updateChallenge', {challenge: this.workingChallenge}); this.$store.dispatch('challenges:updateChallenge', {challenge: this.workingChallenge});
this.ressetWorkingChallenge(); this.resetWorkingChallenge();
this.$root.$emit('hide::modal', 'challenge-modal'); this.$root.$emit('hide::modal', 'challenge-modal');
}, },
toggleCategorySelect () { toggleCategorySelect () {

View File

@@ -27,7 +27,7 @@
.col-12 .col-12
h3(v-once) {{ $t('chat') }} h3(v-once) {{ $t('chat') }}
textarea(:placeholder="!isParty ? $t('chatPlaceHolder') : $t('partyChatPlaceholder')", v-model='newMessage', @keydown='updateCarretPosition') textarea(:placeholder="!isParty ? $t('chatPlaceholder') : $t('partyChatPlaceholder')", v-model='newMessage', @keydown='updateCarretPosition')
autocomplete(:text='newMessage', v-on:select="selectedAutocomplete", :coords='coords', :groupId='groupId') autocomplete(:text='newMessage', v-on:select="selectedAutocomplete", :coords='coords', :groupId='groupId')
button.btn.btn-secondary.send-chat.float-right(v-once, @click='sendMessage()') {{ $t('send') }} button.btn.btn-secondary.send-chat.float-right(v-once, @click='sendMessage()') {{ $t('send') }}
button.btn.btn-secondary.float-left(v-once, @click='fetchRecentMessages()') {{ $t('fetchRecentMessages') }} button.btn.btn-secondary.float-left(v-once, @click='fetchRecentMessages()') {{ $t('fetchRecentMessages') }}
@@ -106,7 +106,19 @@
.section-header .section-header
.row .row
.col-10 .col-10
h3(v-once) {{ $t('description') }} h3(v-once) {{ $t('guildSummary') }}
.col-2
.toggle-up(@click="sections.summary = !sections.summary", v-if="sections.summary")
.svg-icon(v-html="icons.upIcon")
.toggle-down(@click="sections.summary = !sections.summary", v-if="!sections.summary")
.svg-icon(v-html="icons.downIcon")
.section(v-if="sections.summary")
p {{ group.summary }}
.section-header
.row
.col-10
h3 {{ $t('groupDescription') }}
.col-2 .col-2
.toggle-up(@click="sections.description = !sections.description", v-if="sections.description") .toggle-up(@click="sections.description = !sections.description", v-if="sections.description")
.svg-icon(v-html="icons.upIcon") .svg-icon(v-html="icons.upIcon")
@@ -115,18 +127,6 @@
.section(v-if="sections.description") .section(v-if="sections.description")
p {{ group.description }} p {{ group.description }}
.section-header
.row
.col-10
h3 {{ $t('guildInformation') }}
.col-2
.toggle-up(@click="sections.information = !sections.information", v-if="sections.information")
.svg-icon(v-html="icons.upIcon")
.toggle-down(@click="sections.information = !sections.information", v-if="!sections.information")
.svg-icon(v-html="icons.downIcon")
.section(v-if="sections.information")
p {{ group.information }}
.section-header.challenge .section-header.challenge
.row .row
.col-10.information-header .col-10.information-header
@@ -442,8 +442,8 @@ export default {
selectedQuest: {}, selectedQuest: {},
sections: { sections: {
quest: true, quest: true,
summary: true,
description: true, description: true,
information: true,
challenges: true, challenges: true,
}, },
newMessage: '', newMessage: '',

View File

@@ -4,17 +4,16 @@
.form-group .form-group
label label
strong(v-once) {{$t('name')}} * strong(v-once) {{$t('name')}} *
b-form-input(type="text", :placeholder="$t('newGuildPlaceHolder')", v-model="workingGuild.name") b-form-input(type="text", :placeholder="$t('newGuildPlaceholder')", v-model="workingGuild.name")
.form-group(v-if='workingGuild.id && members.length > 0') .form-group(v-if='workingGuild.id && members.length > 0')
label label
strong(v-once) {{$t('leader')}} * strong(v-once) {{$t('guildOrPartyLeader')}} *
select.form-control(v-model="workingGuild.newLeader") select.form-control(v-model="workingGuild.newLeader")
option(v-for='member in members', :value="member._id") {{ member.profile.name }} option(v-for='member in members', :value="member._id") {{ member.profile.name }}
.form-group .form-group
label label
strong(v-once) {{$t('privacySettings')}}* strong(v-once) {{$t('privacySettings')}} *
br br
label.custom-control.custom-checkbox label.custom-control.custom-checkbox
input.custom-control-input(type="checkbox", v-model="workingGuild.onlyLeaderCreatesChallenges") input.custom-control-input(type="checkbox", v-model="workingGuild.onlyLeaderCreatesChallenges")
@@ -43,16 +42,18 @@
span.custom-control-indicator span.custom-control-indicator
span.custom-control-description(v-once) {{ $t('allowGuildInvationsFromNonMembers') }} span.custom-control-description(v-once) {{ $t('allowGuildInvationsFromNonMembers') }}
.form-group
label
strong(v-once) {{$t('description')}} *
div.description-count {{charactersRemaining}} {{ $t('charactersRemaining') }}
textarea.form-control(:placeholder="isParty ? $t('partyDescriptionPlaceHolder') : $t('guildDescriptionPlaceHolder')", v-model="workingGuild.description")
.form-group(v-if='!creatingParty') .form-group(v-if='!creatingParty')
label label
strong(v-once) {{$t('guildInformation')}} * strong(v-once) {{$t('guildSummary')}} *
textarea.form-control(:placeholder="isParty ? $t('partyInformationPlaceHolder'): $t('guildInformationPlaceHolder')", v-model="workingGuild.guildInformation") div.summary-count {{charactersRemaining}} {{ $t('charactersRemaining') }}
textarea.form-control.summary-textarea(:placeholder="$t('guildSummaryPlaceholder')", v-model="workingGuild.summary")
// @TODO: need summary only for PUBLIC GUILDS, not for tavern, private guilds, or party
.form-group
label
strong(v-once) {{$t('groupDescription')}} *
a.float-right {{ $t('markdownFormattingHelp') }}
b-form-input.description-textarea(type="text", textarea, :placeholder="creatingParty ? $t('partyDescriptionPlaceholder') : $t('guildDescriptionPlaceholder')", v-model="workingGuild.description")
.form-group(v-if='creatingParty && !workingGuild.id') .form-group(v-if='creatingParty && !workingGuild.id')
span span
@@ -60,7 +61,7 @@
.form-group(style='position: relative;', v-if='!creatingParty && !isParty') .form-group(style='position: relative;', v-if='!creatingParty && !isParty')
label label
strong(v-once) {{$t('categories')}}* strong(v-once) {{$t('categories')}} *
div.category-wrap(@click.prevent="toggleCategorySelect") div.category-wrap(@click.prevent="toggleCategorySelect")
span.category-select(v-if='workingGuild.categories.length === 0') {{$t('none')}} span.category-select(v-if='workingGuild.categories.length === 0') {{$t('none')}}
.category-label(v-for='category in workingGuild.categories') {{$t(categoriesHashByKey[category])}} .category-label(v-for='category in workingGuild.categories') {{$t(categoriesHashByKey[category])}}
@@ -74,11 +75,12 @@
span.custom-control-indicator span.custom-control-indicator
span.custom-control-description(v-once) {{ $t(group.label) }} span.custom-control-description(v-once) {{ $t(group.label) }}
button.btn.btn-primary(@click.prevent="toggleCategorySelect") {{$t('close')}} button.btn.btn-primary(@click.prevent="toggleCategorySelect") {{$t('close')}}
// @TODO: need categories only for PUBLIC GUILDS, not for tavern, private guilds, or party
.form-group(v-if='inviteMembers && !workingGuild.id') .form-group(v-if='inviteMembers && !workingGuild.id')
label label
strong(v-once) Invite via Email or User ID strong(v-once) Invite via Email or User ID
p Invite users via a valid email or 36-digit User ID. If an email isnt registered yet, well invite them to join. p(v-once) {{$t('inviteMembersHowTo')}} *
div div
div(v-for='(member, index) in membersToInvite') div(v-for='(member, index) in membersToInvite')
@@ -108,19 +110,27 @@
height: 150px; height: 150px;
} }
.description-count, .gem-description { .summary-count, .gem-description {
font-size: 12px; font-size: 12px;
line-height: 1.33; line-height: 1.33;
text-align: center; margin-top: 1em;
color: $gray-200; color: $gray-200;
} }
.description-count { .summary-count {
text-align: right; text-align: right;
} }
.gem-description { .gem-description {
margin-top: 1em; text-align: center;
}
.summary-textarea {
height: 90px;
}
.description-textarea {
height: 220px;
} }
.item-with-icon { .item-with-icon {
@@ -139,10 +149,6 @@
} }
} }
.description-count {
margin-top: 1em;
}
.icon { .icon {
margin-left: .5em; margin-left: .5em;
display: inline-block; display: inline-block;
@@ -161,6 +167,8 @@ import toggleSwitch from 'client/components/ui/toggleSwitch';
import gemIcon from 'assets/svg/gem.svg'; import gemIcon from 'assets/svg/gem.svg';
import informationIcon from 'assets/svg/information.svg'; import informationIcon from 'assets/svg/information.svg';
import { MAX_SUMMARY_SIZE_FOR_GUILDS } from '../../../common/script/constants';
// @TODO: Not sure the best way to pass party creating status // @TODO: Not sure the best way to pass party creating status
// Since we need the modal in the header, passing props doesn't work // Since we need the modal in the header, passing props doesn't work
// because we can't import the create group in the index of groups // because we can't import the create group in the index of groups
@@ -184,8 +192,8 @@ export default {
name: '', name: '',
type: 'guild', type: 'guild',
privacy: 'private', privacy: 'private',
summary: '',
description: '', description: '',
guildInformation: '',
categories: [], categories: [],
onlyLeaderCreatesChallenges: true, onlyLeaderCreatesChallenges: true,
guildLeaderCantBeMessaged: true, guildLeaderCantBeMessaged: true,
@@ -282,9 +290,8 @@ export default {
this.workingGuild.name = editingGroup.name; this.workingGuild.name = editingGroup.name;
this.workingGuild.type = editingGroup.type; this.workingGuild.type = editingGroup.type;
this.workingGuild.privacy = editingGroup.privacy; this.workingGuild.privacy = editingGroup.privacy;
if (editingGroup.description) this.workingGuild.description = editingGroup.description;
if (editingGroup.information) this.workingGuild.information = editingGroup.information;
if (editingGroup.summary) this.workingGuild.summary = editingGroup.summary; if (editingGroup.summary) this.workingGuild.summary = editingGroup.summary;
if (editingGroup.description) this.workingGuild.description = editingGroup.description;
if (editingGroup._id) this.workingGuild.id = editingGroup._id; if (editingGroup._id) this.workingGuild.id = editingGroup._id;
if (editingGroup.leader._id) this.workingGuild.newLeader = editingGroup.leader._id; if (editingGroup.leader._id) this.workingGuild.newLeader = editingGroup.leader._id;
if (editingGroup._id) this.getMembers(); if (editingGroup._id) this.getMembers();
@@ -292,7 +299,8 @@ export default {
}, },
computed: { computed: {
charactersRemaining () { charactersRemaining () {
return 500 - this.workingGuild.description.length; let currentLength = this.workingGuild.summary ? this.workingGuild.summary.length : 0;
return MAX_SUMMARY_SIZE_FOR_GUILDS - currentLength;
}, },
title () { title () {
if (this.creatingParty) return this.$t('createParty'); if (this.creatingParty) return this.$t('createParty');
@@ -339,14 +347,26 @@ export default {
} }
if (!this.workingGuild.name || !this.workingGuild.description) { if (!this.workingGuild.name || !this.workingGuild.description) {
// @TODO: Add proper notifications // @TODO: Add proper notifications - split this out into two, make errors translatable. Suggestion: `<% fieldName %> is required` for all errors where possible, where `fieldName` is inserted as the translatable string that's used for the field header.
alert('Enter a name and description'); alert('Enter a name and description');
return; return;
} }
if (this.workingGuild.description.length > 500) { if (!this.workingGuild.summary) {
// @TODO: Add proper notifications. Summary is mandatory for only public guilds (not tavern, private guilds, parties)
alert('Enter a summary');
return;
}
if (this.workingGuild.summary.length > MAX_SUMMARY_SIZE_FOR_GUILDS) {
// @TODO: Add proper notifications. Summary is mandatory for only public guilds (not tavern, private guilds, parties)
alert('Summary is too long');
return;
}
if (!this.workingGuild.categories || this.workingGuild.categories.length === 0) {
// @TODO: Add proper notifications // @TODO: Add proper notifications
alert('Description is too long'); alert('One or more categories must be selected');
return; return;
} }

View File

@@ -37,6 +37,7 @@
"prizePop": "If someone can 'win' your challenge, you can optionally award that winner a Gem prize. The maximum number you can award is the number of gems you own (plus the number of guild gems, if you created this challenge's guild). Note: This prize can't be changed later.", "prizePop": "If someone can 'win' your challenge, you can optionally award that winner a Gem prize. The maximum number you can award is the number of gems you own (plus the number of guild gems, if you created this challenge's guild). Note: This prize can't be changed later.",
"prizePopTavern": "If someone can 'win' your challenge, you can award that winner a Gem prize. Max = number of gems you own. Note: This prize can't be changed later and Tavern challenges will not be refunded if the challenge is cancelled.", "prizePopTavern": "If someone can 'win' your challenge, you can award that winner a Gem prize. Max = number of gems you own. Note: This prize can't be changed later and Tavern challenges will not be refunded if the challenge is cancelled.",
"publicChallenges": "Minimum 1 Gem for <strong> public challenges </strong> (helps prevent spam, it really does).", "publicChallenges": "Minimum 1 Gem for <strong> public challenges </strong> (helps prevent spam, it really does).",
"publicChallengesTitle": "Public Challenges",
"officialChallenge": "Official Habitica Challenge", "officialChallenge": "Official Habitica Challenge",
"by": "by", "by": "by",
"participants": "<%= membercount %> Participants", "participants": "<%= membercount %> Participants",

View File

@@ -142,6 +142,7 @@
"partyMembersInfo": "Your party currently has <%= memberCount %> members and <%= invitationCount %> pending invitations. The limit of members in a party is <%= limitMembers %>. Invitations above this limit cannot be sent.", "partyMembersInfo": "Your party currently has <%= memberCount %> members and <%= invitationCount %> pending invitations. The limit of members in a party is <%= limitMembers %>. Invitations above this limit cannot be sent.",
"inviteByEmail": "Invite by Email", "inviteByEmail": "Invite by Email",
"inviteByEmailExplanation": "If a friend joins Habitica via your email, they'll automatically be invited to your party!", "inviteByEmailExplanation": "If a friend joins Habitica via your email, they'll automatically be invited to your party!",
"inviteMembersHowTo": "Invite people via a valid email or 36-digit User ID. If an email isn't registered yet, we'll invite them to join Habitica.",
"inviteFriendsNow": "Invite Friends Now", "inviteFriendsNow": "Invite Friends Now",
"inviteFriendsLater": "Invite Friends Later", "inviteFriendsLater": "Invite Friends Later",
"inviteAlertInfo": "If you have friends already using Habitica, invite them by <a href='http://habitica.wikia.com/wiki/API_Options' target='_blank'>User ID</a> here.", "inviteAlertInfo": "If you have friends already using Habitica, invite them by <a href='http://habitica.wikia.com/wiki/API_Options' target='_blank'>User ID</a> here.",

View File

@@ -5,10 +5,10 @@
"costumePopoverText": "Select \"Use Costume\" to equip items to your avatar without affecting the stats from your Battle Gear! This means that you can dress up your avatar in whatever outfit you like while still having your best Battle Gear equipped.", "costumePopoverText": "Select \"Use Costume\" to equip items to your avatar without affecting the stats from your Battle Gear! This means that you can dress up your avatar in whatever outfit you like while still having your best Battle Gear equipped.",
"autoEquipPopoverText": "Select this option to automatically equip gear as soon as you purchase it.", "autoEquipPopoverText": "Select this option to automatically equip gear as soon as you purchase it.",
"costumeDisabled": "You have disabled your costume.", "costumeDisabled": "You have disabled your costume.",
"newGuildPlaceHolder": "Enter your name", "newGuildPlaceholder": "Enter your guild's name.",
"guildMembers": "Guild Members", "guildMembers": "Guild Members",
"guildBank": "Guild Bank", "guildBank": "Guild Bank",
"chatPlaceHolder": "Type your message to Guild members here", "chatPlaceholder": "Type your message to Guild members here",
"today": "Today", "today": "Today",
"theseAreYourTasks": "These are your <%= taskType %>", "theseAreYourTasks": "These are your <%= taskType %>",
"habitsDesc": "Habits don't have a rigid schedule. You can check them off multiple times per day.", "habitsDesc": "Habits don't have a rigid schedule. You can check them off multiple times per day.",
@@ -28,7 +28,6 @@
"inviteToGuild": "Invite to Guild", "inviteToGuild": "Invite to Guild",
"messageGuildLeader": "Message Guild Leader", "messageGuildLeader": "Message Guild Leader",
"donateGems": "Donate Gems", "donateGems": "Donate Gems",
"guildInformation": "Guild Information",
"updateGuild": "Update Guild", "updateGuild": "Update Guild",
"viewMembers": "View Members", "viewMembers": "View Members",
"items": "Items", "items": "Items",
@@ -70,6 +69,7 @@
"music": "Music", "music": "Music",
"relationship": "Relationships", "relationship": "Relationships",
"scienceTech": "Science & Technology", "scienceTech": "Science & Technology",
"guildOrPartyLeader": "Leader",
"guildLeader": "Guild Leader", "guildLeader": "Guild Leader",
"member": "Member", "member": "Member",
"goldTier": "Gold Tier", "goldTier": "Gold Tier",
@@ -82,7 +82,11 @@
"privateGuild": "Private Guild", "privateGuild": "Private Guild",
"allowGuildInvationsFromNonMembers": "Allow Guild invitations from non-members", "allowGuildInvationsFromNonMembers": "Allow Guild invitations from non-members",
"charactersRemaining": "characters remaining", "charactersRemaining": "characters remaining",
"guildDescriptionPlaceHolder": "Write a short description advertising your Guild to other Habiticans. What is the main purpose of your Guild and why should people join it? Try to include useful keywords in the description so that Habiticans can easily find it when they search!", "guildSummary": "Summary",
"guildSummaryPlaceholder": "Write a short description advertising your Guild to other Habiticans. What is the main purpose of your Guild and why should people join it? Try to include useful keywords in the summary so that Habiticans can easily find it when they search!",
"groupDescription": "Description",
"guildDescriptionPlaceholder": "Use this section to go into more detail about everything that Guild members should know about your Guild. Useful tips, helpful links, and encouraging statements all go here!",
"partyDescriptionPlaceholder": "This is our party's description. It describes what we do in this party. If you want to learn more about what we do in this party, read the description. Party on.",
"guildGemCostInfo": "A Gem cost promotes high quality Guilds and is transferred into your Guild's bank.", "guildGemCostInfo": "A Gem cost promotes high quality Guilds and is transferred into your Guild's bank.",
"categories": "Categories", "categories": "Categories",
"noGuildsTitle": "You arent a member of any Guilds.", "noGuildsTitle": "You arent a member of any Guilds.",
@@ -101,7 +105,6 @@
"haveNoChallenges": "You dont have any Challenges", "haveNoChallenges": "You dont have any Challenges",
"challengeDetails": "Challenges are community events in which players compete and earn prizes by completing a group of related tasks.", "challengeDetails": "Challenges are community events in which players compete and earn prizes by completing a group of related tasks.",
"createParty": "Create a Party", "createParty": "Create a Party",
"partyDescriptionPlaceHolder": "This is our partys description. It describes what we do in this party. If you want to learn more about what we do in this party, read the description. Party on.",
"inviteMembersNow": "Would you like to invite users now?", "inviteMembersNow": "Would you like to invite users now?",
"playInPartyTitle": "Play Habitica in a Party!", "playInPartyTitle": "Play Habitica in a Party!",
"playInPartyDescription": "Take on amazing quests with friends or on your own. Battle monsters, create Challenges, and help yourself stay accountable through Parties.", "playInPartyDescription": "Take on amazing quests with friends or on your own. Battle monsters, create Challenges, and help yourself stay accountable through Parties.",
@@ -114,7 +117,6 @@
"inviteToPartyOrQuest": "Invite Party to Quest", "inviteToPartyOrQuest": "Invite Party to Quest",
"inviteInformation": "Clicking “Invite” will send an invitation to your party members. When all members have accepted or denied, the Quest begins.", "inviteInformation": "Clicking “Invite” will send an invitation to your party members. When all members have accepted or denied, the Quest begins.",
"questOwnerRewards": "Quest Owner Rewards", "questOwnerRewards": "Quest Owner Rewards",
"guildInformationPlaceHolder": "Use this section to go into more detail about everything that Guild members should know about your Guild. Useful tips, helpful links, and encouraging statements all go here!",
"updateParty": "Update Party", "updateParty": "Update Party",
"upgrade": "Upgrade", "upgrade": "Upgrade",
"signUpWithSocial": "Sign up with <%= social %>", "signUpWithSocial": "Sign up with <%= social %>",
@@ -206,11 +208,12 @@
"awardWinners": "Award Winners", "awardWinners": "Award Winners",
"doYouWantedToDeleteChallenge": "Do you want to delete this Callenge?", "doYouWantedToDeleteChallenge": "Do you want to delete this Callenge?",
"deleteChallenge": "Delete Challenge", "deleteChallenge": "Delete Challenge",
"challengeNamePlaceHolder": "What is your Challenge name?", "challengeNamePlaceholder": "What is your Challenge name?",
"challengeDescriptionPlaceHolder": "Write a short description advertising your Challenge to other Habiticans. What is the main purpose of your Challenge and why should people join it? Try to include useful keywords in the description so that Habiticans can easily find it when they search!", "challengeSummary": "Summary",
"challengeSummaryPlaceholder": "Write a short description advertising your Challenge to other Habiticans. What is the main purpose of your Challenge and why should people join it? Try to include useful keywords in the description so that Habiticans can easily find it when they search!",
"challengeDescriptionPlaceholder": "Use this section to go into more detail about everything that Challenge participants should know about your Challenge.",
"markdownFormattingHelp": "Markdown formatting help", "markdownFormattingHelp": "Markdown formatting help",
"challengeInformationPlaceHolder": "Write a short description advertising your Challenge to other Habiticans. What is the main purpose of your Challenge and why should people join it? Try to include useful keywords in the description so that Habiticans can easily find it when they search!", "challengeGuild": "Add to",
"where": "Where*",
"challengeMinimum": "Minimum 1 Gem for public Challenges (helps prevent spam, it really does).", "challengeMinimum": "Minimum 1 Gem for public Challenges (helps prevent spam, it really does).",
"editATask": "Edit a <%= type %>", "editATask": "Edit a <%= type %>",
"createTask": "Create <%= type %>", "createTask": "Create <%= type %>",
@@ -289,8 +292,7 @@
"welcomeBack": "Welcome back!", "welcomeBack": "Welcome back!",
"checkOffYesterDailies": "Check off any Dailies you did yesterday:", "checkOffYesterDailies": "Check off any Dailies you did yesterday:",
"introTour": "Here we are! Ive filled out some Tasks for you based on your interests, so you can get started right away. Click a Task to edit or add new Tasks to fit your routine!", "introTour": "Here we are! Ive filled out some Tasks for you based on your interests, so you can get started right away. Click a Task to edit or add new Tasks to fit your routine!",
"leader": "Leader", "partyInformationPlaceholder": "Write a message to your Party members here!",
"partyInformationPlaceHolder": "Write a message to your Party members here!",
"selectPartyMember": "Select a Party Member", "selectPartyMember": "Select a Party Member",
"errorNotInParty": "You are not in a Party", "errorNotInParty": "You are not in a Party",
"health_wellness": "Health & Wellness" "health_wellness": "Health & Wellness"

View File

@@ -6,6 +6,9 @@ export const MAX_INCENTIVES = 500;
export const TAVERN_ID = '00000000-0000-4000-A000-000000000000'; export const TAVERN_ID = '00000000-0000-4000-A000-000000000000';
export const LARGE_GROUP_COUNT_MESSAGE_CUTOFF = 5000; export const LARGE_GROUP_COUNT_MESSAGE_CUTOFF = 5000;
export const MAX_SUMMARY_SIZE_FOR_GUILDS = 500;
export const MAX_SUMMARY_SIZE_FOR_CHALLENGES = 250;
export const MIN_SHORTNAME_SIZE_FOR_CHALLENGES = 3;
export const SUPPORTED_SOCIAL_NETWORKS = [ export const SUPPORTED_SOCIAL_NETWORKS = [
{key: 'facebook', name: 'Facebook'}, {key: 'facebook', name: 'Facebook'},

View File

@@ -25,6 +25,9 @@ import {
MAX_INCENTIVES, MAX_INCENTIVES,
TAVERN_ID, TAVERN_ID,
LARGE_GROUP_COUNT_MESSAGE_CUTOFF, LARGE_GROUP_COUNT_MESSAGE_CUTOFF,
MAX_SUMMARY_SIZE_FOR_GUILDS,
MAX_SUMMARY_SIZE_FOR_CHALLENGES,
MIN_SHORTNAME_SIZE_FOR_CHALLENGES,
SUPPORTED_SOCIAL_NETWORKS, SUPPORTED_SOCIAL_NETWORKS,
GUILDS_PER_PAGE, GUILDS_PER_PAGE,
PARTY_LIMIT_MEMBERS, PARTY_LIMIT_MEMBERS,
@@ -33,6 +36,9 @@ import {
api.constants = { api.constants = {
MAX_INCENTIVES, MAX_INCENTIVES,
LARGE_GROUP_COUNT_MESSAGE_CUTOFF, LARGE_GROUP_COUNT_MESSAGE_CUTOFF,
MAX_SUMMARY_SIZE_FOR_GUILDS,
MAX_SUMMARY_SIZE_FOR_CHALLENGES,
MIN_SHORTNAME_SIZE_FOR_CHALLENGES,
SUPPORTED_SOCIAL_NETWORKS, SUPPORTED_SOCIAL_NETWORKS,
GUILDS_PER_PAGE, GUILDS_PER_PAGE,
PARTY_LIMIT_MEMBERS, PARTY_LIMIT_MEMBERS,

View File

@@ -154,7 +154,8 @@ let api = {};
* @apiParam (Body) {UUID} challenge.groupId The id of the group to which the challenge belongs * @apiParam (Body) {UUID} challenge.groupId The id of the group to which the challenge belongs
* @apiParam (Body) {String} challenge.name The full name of the challenge * @apiParam (Body) {String} challenge.name The full name of the challenge
* @apiParam (Body) {String} challenge.shortName A shortened name for the challenge, to be used as a tag * @apiParam (Body) {String} challenge.shortName A shortened name for the challenge, to be used as a tag
* @apiParam (Body) {String} [challenge.description] A description of the challenge * @apiParam (Body) {String} [challenge.summary] A short summary advertising the main purpose of the challenge; maximum 250 characters; if not supplied, challenge.name will be used
* @apiParam (Body) {String} [challenge.description] A detailed description of the challenge
* @apiParam (Body) {Boolean} [official=false] Whether or not a challenge is an official Habitica challenge (requires admin) * @apiParam (Body) {Boolean} [official=false] Whether or not a challenge is an official Habitica challenge (requires admin)
* @apiParam (Body) {Number} [challenge.prize=0] Number of gems offered as a prize to challenge winner * @apiParam (Body) {Number} [challenge.prize=0] Number of gems offered as a prize to challenge winner
* *
@@ -220,6 +221,9 @@ api.createChallenge = {
group.challengeCount += 1; group.challengeCount += 1;
if (!req.body.summary) {
req.body.summary = req.body.name;
}
req.body.leader = user._id; req.body.leader = user._id;
req.body.official = user.contributor.admin && req.body.official ? true : false; req.body.official = user.contributor.admin && req.body.official ? true : false;
let challenge = new Challenge(Challenge.sanitize(req.body)); let challenge = new Challenge(Challenge.sanitize(req.body));
@@ -591,6 +595,7 @@ api.exportChallengeCsv = {
* *
* @apiParam (Path) {UUID} challengeId The challenge _id * @apiParam (Path) {UUID} challengeId The challenge _id
* @apiParam (Body) {String} [challenge.name] The new full name of the challenge. * @apiParam (Body) {String} [challenge.name] The new full name of the challenge.
* @apiParam (Body) {String} [challenge.summary] The new challenge summary.
* @apiParam (Body) {String} [challenge.description] The new challenge description. * @apiParam (Body) {String} [challenge.description] The new challenge description.
* @apiParam (Body) {String} [challenge.leader] The UUID of the new challenge leader. * @apiParam (Body) {String} [challenge.leader] The UUID of the new challenge leader.
* *

View File

@@ -18,9 +18,13 @@ import { syncableAttrs } from '../libs/taskManager';
const Schema = mongoose.Schema; const Schema = mongoose.Schema;
const MIN_SHORTNAME_SIZE_FOR_CHALLENGES = shared.constants.MIN_SHORTNAME_SIZE_FOR_CHALLENGES;
const MAX_SUMMARY_SIZE_FOR_CHALLENGES = shared.constants.MAX_SUMMARY_SIZE_FOR_CHALLENGES;
let schema = new Schema({ let schema = new Schema({
name: {type: String, required: true}, name: {type: String, required: true},
shortName: {type: String, required: true, minlength: 3}, shortName: {type: String, required: true, minlength: MIN_SHORTNAME_SIZE_FOR_CHALLENGES},
summary: {type: String, maxlength: MAX_SUMMARY_SIZE_FOR_CHALLENGES},
description: String, description: String,
official: {type: Boolean, default: false}, official: {type: Boolean, default: false},
tasksOrder: { tasksOrder: {
@@ -43,6 +47,18 @@ schema.plugin(baseModel, {
timestamps: true, timestamps: true,
}); });
schema.pre('init', function ensureSummaryIsFetched (next, chal) {
// The Vue website makes the summary be mandatory for all new challenges, but the
// Angular website did not, and the API does not yet for backwards-compatibilty.
// When any challenge without a summary is fetched from the database, this code
// supplies the name as the summary. This can be removed when all challenges have
// a summary and the API makes it mandatory (a breaking change!)
if (!chal.summary) {
chal.summary = chal.name ? chal.name.substring(0, MAX_SUMMARY_SIZE_FOR_CHALLENGES) : ' ';
}
next();
});
// A list of additional fields that cannot be updated (but can be set on creation) // A list of additional fields that cannot be updated (but can be set on creation)
let noUpdate = ['group', 'official', 'shortName', 'prize']; let noUpdate = ['group', 'official', 'shortName', 'prize'];
schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) {
@@ -91,6 +107,7 @@ schema.methods.syncToUser = async function syncChallengeToUser (user) {
if (i !== -1) { if (i !== -1) {
if (userTags[i].name !== challenge.shortName) { if (userTags[i].name !== challenge.shortName) {
// update the name - it's been changed since // update the name - it's been changed since
// @TODO: We probably want to remove this. Owner is not allowed to change participant's copy of the tag.
userTags[i].name = challenge.shortName; userTags[i].name = challenge.shortName;
} }
} else { } else {

View File

@@ -40,6 +40,7 @@ export const TAVERN_ID = shared.TAVERN_ID;
const NO_CHAT_NOTIFICATIONS = [TAVERN_ID]; const NO_CHAT_NOTIFICATIONS = [TAVERN_ID];
const LARGE_GROUP_COUNT_MESSAGE_CUTOFF = shared.constants.LARGE_GROUP_COUNT_MESSAGE_CUTOFF; const LARGE_GROUP_COUNT_MESSAGE_CUTOFF = shared.constants.LARGE_GROUP_COUNT_MESSAGE_CUTOFF;
const MAX_SUMMARY_SIZE_FOR_GUILDS = shared.constants.MAX_SUMMARY_SIZE_FOR_GUILDS;
const GUILDS_PER_PAGE = shared.constants.GUILDS_PER_PAGE; const GUILDS_PER_PAGE = shared.constants.GUILDS_PER_PAGE;
const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true'; const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true';
@@ -58,6 +59,7 @@ export const SPAM_MIN_EXEMPT_CONTRIB_LEVEL = 4;
export let schema = new Schema({ export let schema = new Schema({
name: {type: String, required: true}, name: {type: String, required: true},
summary: {type: String, maxlength: MAX_SUMMARY_SIZE_FOR_GUILDS},
description: String, description: String,
leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true}, leader: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.'], required: true},
type: {type: String, enum: ['guild', 'party'], required: true}, type: {type: String, enum: ['guild', 'party'], required: true},
@@ -137,6 +139,24 @@ schema.plugin(baseModel, {
}, },
}); });
schema.pre('init', function ensureSummaryIsFetched (next, group) {
// The Vue website makes the summary be mandatory for all new groups, but the
// Angular website did not, and the API does not yet for backwards-compatibilty.
// When any public guild without a summary is fetched from the database, this code
// supplies the name as the summary. This can be removed when all public guilds have
// a summary and the API makes it mandatory (a breaking change!)
// NOTE: these groups do NOT need summaries: Tavern, private guilds, parties
// ALSO NOTE: it's possible for a private guild to become public and vice versa when
// a guild owner requests it of an admin so that must be taken into account
// when making the summary mandatory - process for changing privacy:
// http://habitica.wikia.com/wiki/Guilds#Changing_a_Guild_from_Private_to_Public_or_Public_to_Private
// Maybe because of that we'd want to keep this code here forever. @TODO: think about that.
if (!group.summary) {
group.summary = group.name ? group.name.substring(0, MAX_SUMMARY_SIZE_FOR_GUILDS) : ' ';
}
next();
});
// A list of additional fields that cannot be updated (but can be set on creation) // A list of additional fields that cannot be updated (but can be set on creation)
let noUpdate = ['privacy', 'type']; let noUpdate = ['privacy', 'type'];
schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) { schema.statics.sanitizeUpdate = function sanitizeUpdate (updateObj) {
@@ -319,7 +339,7 @@ schema.statics.toJSONCleanChat = function groupToJSONCleanChat (group, user) {
}; };
/** /**
* Checks inivtation uuids and emails for possible errors. * Checks invitation uuids and emails for possible errors.
* *
* @param uuids An array of user ids * @param uuids An array of user ids
* @param emails An array of emails * @param emails An array of emails