Merge branch 'develop' into release

This commit is contained in:
Sabe Jones
2017-04-03 12:20:25 -05:00
committed by GitHub
31 changed files with 1250 additions and 752 deletions
+8 -8
View File
@@ -16,18 +16,18 @@ before_script:
- npm run test:build
- cp config.json.example config.json
- if [ $REQUIRES_SERVER ]; then until nc -z localhost 27017; do echo Waiting for MongoDB; sleep 1; done; export DISPLAY=:99; fi
after_script:
- ./node_modules/.bin/lcov-result-merger 'coverage/**/*.info' | ./node_modules/coveralls/bin/coveralls.js
script: npm run $TEST
script:
- npm run $TEST
- if [ $COVERAGE ]; then ./node_modules/.bin/lcov-result-merger 'coverage/**/*.info' | ./node_modules/coveralls/bin/coveralls.js; fi
env:
global:
- CXX=g++-4.8
- DISABLE_REQUEST_LOGGING=true
matrix:
- TEST="lint"
- TEST="test:api-v3" REQUIRES_SERVER=true
- TEST="test:api-v3" REQUIRES_SERVER=true COVERAGE=true
- TEST="test:sanity"
- TEST="test:content"
- TEST="test:common"
- TEST="test:karma"
- TEST="client:unit"
- TEST="test:content" COVERAGE=true
- TEST="test:common" COVERAGE=true
- TEST="test:karma" COVERAGE=true
- TEST="client:unit" COVERAGE=true
+2 -2
View File
@@ -280,7 +280,7 @@ gulp.task('test:e2e:safe', ['test:prepare', 'test:prepare:server'], (cb) => {
gulp.task('test:api-v3:unit', (done) => {
let runner = exec(
testBin('mocha test/api/v3/unit --recursive --require ./test/helpers/start-server'),
testBin('node_modules/.bin/istanbul cover --dir coverage/api-v3-unit --report lcovonly node_modules/mocha/bin/_mocha -- test/api/v3/unit --recursive --require ./test/helpers/start-server'),
(err, stdout, stderr) => {
if (err) {
process.exit(1);
@@ -298,7 +298,7 @@ gulp.task('test:api-v3:unit:watch', () => {
gulp.task('test:api-v3:integration', (done) => {
let runner = exec(
testBin('mocha test/api/v3/integration --recursive --require ./test/helpers/start-server'),
testBin('node_modules/.bin/istanbul cover --dir coverage/api-v3-integration --report lcovonly node_modules/mocha/bin/_mocha -- test/api/v3/integration --recursive --require ./test/helpers/start-server'),
{maxBuffer: 500 * 1024},
(err, stdout, stderr) => {
if (err) {
+656 -470
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -139,9 +139,9 @@
"test:api-v3:unit": "gulp test:api-v3:unit",
"test:api-v3:integration": "gulp test:api-v3:integration",
"test:api-v3:integration:separate-server": "NODE_ENV=test gulp test:api-v3:integration:separate-server",
"test:sanity": "mocha test/sanity --recursive",
"test:common": "mocha test/common --recursive",
"test:content": "mocha test/content --recursive",
"test:sanity": "istanbul cover --dir coverage/sanity --report lcovonly node_modules/mocha/bin/_mocha -- test/sanity --recursive",
"test:common": "istanbul cover --dir coverage/common --report lcovonly node_modules/mocha/bin/_mocha -- test/common --recursive",
"test:content": "istanbul cover --dir coverage/content --report lcovonly node_modules/mocha/bin/_mocha -- test/content --recursive",
"test:karma": "karma start test/client-old/spec/karma.conf.js --single-run",
"test:karma:watch": "karma start test/client-old/spec/karma.conf.js",
"test:prepare:webdriver": "webdriver-manager update",
@@ -183,7 +183,7 @@
"grunt-karma": "~0.12.1",
"http-proxy-middleware": "^0.17.0",
"inject-loader": "^3.0.0-beta4",
"istanbul": "^0.3.14",
"istanbul": "^1.1.0-alpha.1",
"karma": "^1.3.0",
"karma-babel-preprocessor": "^6.0.1",
"karma-chai-plugins": "~0.6.0",
@@ -198,7 +198,7 @@
"karma-webpack": "^2.0.2",
"lcov-result-merger": "^1.0.2",
"lolex": "^1.4.0",
"mocha": "^2.3.3",
"mocha": "^3.2.0",
"mongodb": "^2.0.46",
"mongoskin": "~2.1.0",
"monk": "^4.0.0",
@@ -63,15 +63,17 @@ describe('GET /groups/:groupId/invites', () => {
});
it('returns only first 30 invites', async () => {
let group = await generateGroup(user, {type: 'party', name: generateUUID()});
let leader = await generateUser({balance: 4});
let group = await generateGroup(leader, {type: 'guild', privacy: 'public', name: generateUUID()});
let invitesToGenerate = [];
for (let i = 0; i < 31; i++) {
invitesToGenerate.push(generateUser());
}
let generatedInvites = await Promise.all(invitesToGenerate);
await user.post(`/groups/${group._id}/invite`, {uuids: generatedInvites.map(invite => invite._id)});
await leader.post(`/groups/${group._id}/invite`, {uuids: generatedInvites.map(invite => invite._id)});
let res = await user.get('/groups/party/invites');
let res = await leader.get(`/groups/${group._id}/invites`);
expect(res.length).to.equal(30);
res.forEach(member => {
expect(member).to.have.all.keys(['_id', 'id', 'profile']);
@@ -6,6 +6,7 @@ import {
import { v4 as generateUUID } from 'uuid';
const INVITES_LIMIT = 100;
const PARTY_LIMIT_MEMBERS = 30;
describe('Post /groups/:groupId/invite', () => {
let inviter;
@@ -321,6 +322,19 @@ describe('Post /groups/:groupId/invite', () => {
});
});
it('allows 30+ members in a guild', async () => {
let invitesToGenerate = [];
// Generate 30 users to invite (30 + leader = 31 members)
for (let i = 0; i < PARTY_LIMIT_MEMBERS; i++) {
invitesToGenerate.push(generateUser());
}
let generatedInvites = await Promise.all(invitesToGenerate);
// Invite users
expect(await inviter.post(`/groups/${group._id}/invite`, {
uuids: generatedInvites.map(invite => invite._id),
})).to.be.an('array');
});
// @TODO: Add this after we are able to mock the group plan route
xit('returns an error when a non-leader invites to a group plan', async () => {
let userToInvite = await generateUser();
@@ -410,5 +424,36 @@ describe('Post /groups/:groupId/invite', () => {
});
expect((await userToInvite.get('/user')).invitations.party.id).to.equal(party._id);
});
it('allows 30 members in a party', async () => {
let invitesToGenerate = [];
// Generate 29 users to invite (29 + leader = 30 members)
for (let i = 0; i < PARTY_LIMIT_MEMBERS - 1; i++) {
invitesToGenerate.push(generateUser());
}
let generatedInvites = await Promise.all(invitesToGenerate);
// Invite users
expect(await inviter.post(`/groups/${party._id}/invite`, {
uuids: generatedInvites.map(invite => invite._id),
})).to.be.an('array');
});
it('does not allow 30+ members in a party', async () => {
let invitesToGenerate = [];
// Generate 30 users to invite (30 + leader = 31 members)
for (let i = 0; i < PARTY_LIMIT_MEMBERS; i++) {
invitesToGenerate.push(generateUser());
}
let generatedInvites = await Promise.all(invitesToGenerate);
// Invite users
await expect(inviter.post(`/groups/${party._id}/invite`, {
uuids: generatedInvites.map(invite => invite._id),
}))
.to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('partyExceedsMembersLimit', {maxMembersParty: PARTY_LIMIT_MEMBERS}),
});
});
});
});
+70 -96
View File
@@ -4,9 +4,6 @@ import validator from 'validator';
import { sleep } from '../../../../helpers/api-unit.helper';
import { model as Group, INVITES_LIMIT } from '../../../../../website/server/models/group';
import { model as User } from '../../../../../website/server/models/user';
import {
BadRequest,
} from '../../../../../website/server/libs/errors';
import { quests as questScrolls } from '../../../../../website/common/script/content';
import { groupChatReceivedWebhook } from '../../../../../website/server/libs/webhook';
import * as email from '../../../../../website/server/libs/email';
@@ -460,73 +457,67 @@ describe('Group Model', () => {
};
});
it('throws an error if no uuids or emails are passed in', (done) => {
try {
Group.validateInvitations(null, null, res);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('canOnlyInviteEmailUuid');
done();
}
it('throws an error if no uuids or emails are passed in', async () => {
await expect(Group.validateInvitations(null, null, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
});
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('canOnlyInviteEmailUuid');
});
it('throws an error if only uuids are passed in, but they are not an array', (done) => {
try {
Group.validateInvitations({ uuid: 'user-id'}, null, res);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('uuidsMustBeAnArray');
done();
}
it('throws an error if only uuids are passed in, but they are not an array', async () => {
await expect(Group.validateInvitations({ uuid: 'user-id'}, null, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
});
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('uuidsMustBeAnArray');
});
it('throws an error if only emails are passed in, but they are not an array', (done) => {
try {
Group.validateInvitations(null, { emails: 'user@example.com'}, res);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('emailsMustBeAnArray');
done();
}
it('throws an error if only emails are passed in, but they are not an array', async () => {
await expect(Group.validateInvitations(null, { emails: 'user@example.com'}, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
});
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('emailsMustBeAnArray');
});
it('throws an error if emails are not passed in, and uuid array is empty', (done) => {
try {
Group.validateInvitations([], null, res);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('inviteMissingUuid');
done();
}
it('throws an error if emails are not passed in, and uuid array is empty', async () => {
await expect(Group.validateInvitations([], null, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
});
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('inviteMissingUuid');
});
it('throws an error if uuids are not passed in, and email array is empty', (done) => {
try {
Group.validateInvitations(null, [], res);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('inviteMissingEmail');
done();
}
it('throws an error if uuids are not passed in, and email array is empty', async () => {
await expect(Group.validateInvitations(null, [], res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
});
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('inviteMissingEmail');
});
it('throws an error if uuids and emails are passed in as empty arrays', (done) => {
try {
Group.validateInvitations([], [], res);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('inviteMustNotBeEmpty');
done();
}
it('throws an error if uuids and emails are passed in as empty arrays', async () => {
await expect(Group.validateInvitations([], [], res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
});
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('inviteMustNotBeEmpty');
});
it('throws an error if total invites exceed max invite constant', (done) => {
it('throws an error if total invites exceed max invite constant', async () => {
let uuids = [];
let emails = [];
@@ -537,17 +528,16 @@ describe('Group Model', () => {
uuids.push('one-more-uuid'); // to put it over the limit
try {
Group.validateInvitations(uuids, emails, res);
} catch (err) {
expect(err).to.be.an.instanceof(BadRequest);
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT });
done();
}
await expect(Group.validateInvitations(uuids, emails, res)).to.eventually.be.rejected.and.eql({
httpCode: 400,
message: 'Bad request.',
name: 'BadRequest',
});
expect(res.t).to.be.calledOnce;
expect(res.t).to.be.calledWith('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT });
});
it('does not throw error if number of invites matches max invite limit', () => {
it('does not throw error if number of invites matches max invite limit', async () => {
let uuids = [];
let emails = [];
@@ -556,49 +546,33 @@ describe('Group Model', () => {
emails.push(`user-${i}@example.com`);
}
expect(function () {
Group.validateInvitations(uuids, emails, res);
}).to.not.throw();
});
it('does not throw an error if only user ids are passed in', () => {
expect(function () {
Group.validateInvitations(['user-id', 'user-id2'], null, res);
}).to.not.throw();
await Group.validateInvitations(uuids, emails, res);
expect(res.t).to.not.be.called;
});
it('does not throw an error if only emails are passed in', () => {
expect(function () {
Group.validateInvitations(null, ['user1@example.com', 'user2@example.com'], res);
}).to.not.throw();
it('does not throw an error if only user ids are passed in', async () => {
await Group.validateInvitations(['user-id', 'user-id2'], null, res);
expect(res.t).to.not.be.called;
});
it('does not throw an error if both uuids and emails are passed in', () => {
expect(function () {
Group.validateInvitations(['user-id', 'user-id2'], ['user1@example.com', 'user2@example.com'], res);
}).to.not.throw();
it('does not throw an error if only emails are passed in', async () => {
await Group.validateInvitations(null, ['user1@example.com', 'user2@example.com'], res);
expect(res.t).to.not.be.called;
});
it('does not throw an error if uuids are passed in and emails are an empty array', () => {
expect(function () {
Group.validateInvitations(['user-id', 'user-id2'], [], res);
}).to.not.throw();
it('does not throw an error if both uuids and emails are passed in', async () => {
await Group.validateInvitations(['user-id', 'user-id2'], ['user1@example.com', 'user2@example.com'], res);
expect(res.t).to.not.be.called;
});
it('does not throw an error if emails are passed in and uuids are an empty array', () => {
expect(function () {
Group.validateInvitations([], ['user1@example.com', 'user2@example.com'], res);
}).to.not.throw();
it('does not throw an error if uuids are passed in and emails are an empty array', async () => {
await Group.validateInvitations(['user-id', 'user-id2'], [], res);
expect(res.t).to.not.be.called;
});
it('does not throw an error if emails are passed in and uuids are an empty array', async () => {
await Group.validateInvitations([], ['user1@example.com', 'user2@example.com'], res);
expect(res.t).to.not.be.called;
});
});
@@ -0,0 +1,35 @@
describe('task Directive', () => {
var compile, scope, directiveElem, $modal;
beforeEach(function(){
module(function($provide) {
$modal = {
open: sandbox.spy(),
};
$provide.value('$modal', $modal);
});
inject(function($compile, $rootScope, $templateCache) {
compile = $compile;
scope = $rootScope.$new();
$templateCache.put('templates/task.html', '<div>Task</div>');
});
directiveElem = getCompiledElement();
});
function getCompiledElement(){
var element = angular.element('<task></task>');
var compiledElement = compile(element)(scope);
scope.$digest();
return compiledElement;
}
xit('opens task note modal', () => {
scope.showNoteDetails();
expect($modal.open).to.be.calledOnce;
});
})
+5 -2
View File
@@ -81,8 +81,11 @@ module.exports = function karmaConfig (config) {
},
coverageReporter: {
type: 'lcov',
dir: 'coverage/karma',
reporters: [
{ type: 'lcov', subdir: '.' },
{ type: 'text-summary' },
],
dir: '../../../coverage/karma',
},
// Enable mocha-style reporting, for better test visibility
+1 -1
View File
@@ -28,7 +28,7 @@ module.exports = function (config) {
noInfo: true,
},
coverageReporter: {
dir: './coverage',
dir: '../../../coverage/client-unit',
reporters: [
{ type: 'lcov', subdir: '.' },
{ type: 'text-summary' },
+2
View File
@@ -178,6 +178,8 @@
hrpg-button-color-mixin(lighten($color-toolbar,32.8%))
.toolbar-subscribe-button, .toolbar-controls .toolbar-subscribe-button
@extend $hrpg-button
button
min-width: 130px
@media screen and (max-width: $xs-max-screen-width)
.toolbar-toggle
display: none
@@ -2,6 +2,8 @@
habitrpg.controller("GroupsCtrl", ['$scope', '$rootScope', 'Shared', 'Groups', '$http', '$q', 'User', 'Members', '$state', 'Notification',
function($scope, $rootScope, Shared, Groups, $http, $q, User, Members, $state, Notification) {
$scope.PARTY_LIMIT_MEMBERS = Shared.constants.PARTY_LIMIT_MEMBERS;
$scope.inviteOrStartParty = Groups.inviteOrStartParty;
$scope.isMemberOfPendingQuest = function (userid, group) {
if (!group.quest || !group.quest.members) return false;
@@ -1,24 +1,41 @@
'use strict';
(function(){
angular
.module('habitrpg')
.directive('task', task);
task.$inject = [
'Shared',
];
function task(Shared) {
return {
restrict: 'E',
templateUrl: 'templates/task.html',
scope: true,
link: function($scope, element, attrs) {
$scope.getClasses = function (task, user, list, main) {
return Shared.taskClasses(task, user.filters, user.preferences.dayStart, user.lastCron, list.showCompleted, main);
}
}
}
}
}());
'use strict';
(function(){
angular
.module('habitrpg')
.directive('task', task);
task.$inject = [
'Shared',
'$modal',
];
function task(Shared, $modal) {
return {
restrict: 'E',
templateUrl: 'templates/task.html',
scope: true,
link: function($scope, element, attrs) {
$scope.getClasses = function (task, user, list, main) {
return Shared.taskClasses(task, user.filters, user.preferences.dayStart, user.lastCron, list.showCompleted, main);
}
$scope.showNoteDetails = function (task) {
task.popoverOpen = false;
$modal.open({
templateUrl: 'modals/task-extra-notes.html',
controller: ['$scope', 'task', function ($scope, task) {
$scope.task = task;
}],
resolve: {
task: function() {
return task;
}
}
})
};
}
}
}
}());
@@ -260,6 +260,7 @@ angular.module('habitrpg')
if (quest.progressDelta && userQuest.boss) {
Notification.quest('questDamage', quest.progressDelta.toFixed(1));
} else if (quest.collection && userQuest.collect) {
user.party.quest.progress.collectedItems++;
Notification.quest('questCollection', quest.collection);
}
}
@@ -70,7 +70,7 @@
"commGuidePara039": "The Back Corner Guild is a free public space to discuss sensitive subjects, and it is carefully moderated. It is not a place for general discussions or conversations. <strong>The Public Space Guidelines still apply, as do all of the Terms and Conditions.</strong> Just because we are wearing long cloaks and clustering in a corner doesn't mean that anything goes! Now pass me that smoldering candle, will you?",
"commGuideHeadingTrello": "Trello Boards",
"commGuidePara040": "<strong>Trello serves as an open forum for suggestions and discussion of site features.</strong> Habitica is ruled by the people in the form of valiant contributors -- we all build the site together. Trello lends structure to our system. Out of consideration for this, <strong>try your best to contain all your thoughts into one comment, instead of commenting many times in a row on the same card. If you think of something new, feel free to edit your original comments.</strong> Please, take pity on those of us who receive a notification for every new comment. Our inboxes can only withstand so much.",
"commGuidePara041": "Habitica uses five different Trello boards:",
"commGuidePara041": "Habitica uses four different Trello boards:",
"commGuideList03A": "The <strong>Main Board</strong> is a place to request and vote on site features.",
"commGuideList03B": "The <strong>Mobile Board</strong> is a place to request and vote on mobile app features.",
"commGuideList03C": "The <strong>Pixel Art Board</strong> is a place to discuss and submit pixel art.",
+3
View File
@@ -135,6 +135,7 @@
"leaderOnlyChallenges": "Only group leader can create challenges",
"sendGift": "Send Gift",
"inviteFriends": "Invite Friends",
"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",
"inviteByEmailExplanation": "If a friend joins Habitica via your email, they'll automatically be invited to your party!",
"inviteFriendsNow": "Invite Friends Now",
@@ -154,6 +155,7 @@
"sendGiftPurchase": "Purchase",
"sendGiftMessagePlaceholder": "Personal message (optional)",
"sendGiftSubscription": "<%= months %> Month(s): $<%= price %> USD",
"gemGiftsAreOptional": "Please note that Habitica will never require you to gift gems to other players. Begging people for gems is a <strong>violation of the Community Guidelines</strong>, and all such instances should be reported to <%= hrefTechAssistanceEmail %>.",
"battleWithFriends": "Battle Monsters With Friends",
"startPartyWithFriends": "Start a Party with your friends!",
"startAParty": "Start a Party",
@@ -201,6 +203,7 @@
"uuidsMustBeAnArray": "User ID invites must be an array.",
"emailsMustBeAnArray": "Email address invites must be an array.",
"canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time",
"partyExceedsMembersLimit": "Party size is limited to <%= maxMembersParty %> members",
"onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!",
"onlyGroupLeaderCanEditTasks": "Not authorized to manage tasks!",
"onlyGroupTasksCanBeAssigned": "Only group tasks can be assigned",
+1
View File
@@ -164,6 +164,7 @@
"confirmScoreNotes": "Confirm task scoring with notes",
"taskScoreNotesTooLong": "Task score notes must be less than 256 characters",
"groupTasksByChallenge": "Group tasks by challenge title",
"taskNotes": "Task Notes",
"monthlyRepeatHelpContent": "This task will be due every X months",
"yearlyRepeatHelpContent": "This task will be due every X years"
}
+3 -1
View File
@@ -12,4 +12,6 @@ export const SUPPORTED_SOCIAL_NETWORKS = [
{key: 'google', name: 'Google'},
];
export const GUILDS_PER_PAGE = 30; // number of guilds to return per page when using pagination
export const GUILDS_PER_PAGE = 30; // number of guilds to return per page when using pagination
export const PARTY_LIMIT_MEMBERS = 30;
+2
View File
@@ -27,6 +27,7 @@ import {
LARGE_GROUP_COUNT_MESSAGE_CUTOFF,
SUPPORTED_SOCIAL_NETWORKS,
GUILDS_PER_PAGE,
PARTY_LIMIT_MEMBERS,
} from './constants';
api.constants = {
@@ -34,6 +35,7 @@ api.constants = {
LARGE_GROUP_COUNT_MESSAGE_CUTOFF,
SUPPORTED_SOCIAL_NETWORKS,
GUILDS_PER_PAGE,
PARTY_LIMIT_MEMBERS,
};
// TODO Move these under api.constants
api.maxLevel = MAX_LEVEL;
+2 -1
View File
@@ -1038,6 +1038,7 @@ async function _inviteByEmail (invite, group, inviter, req, res) {
* @apiError (400) {BadRequest} MustBeArray The `uuids` or `emails` body param was not an array.
* @apiError (400) {BadRequest} TooManyInvites A max of 100 invites (combined emails and user ids) can
* be sent out at a time.
* @apiError (400) {BadRequest} ExceedsMembersLimit A max of 30 members can join a party.
*
* @apiError (401) {NotAuthorized} UserAlreadyInvited The user has already been invited to the group.
* @apiError (401) {NotAuthorized} UserAlreadyInGroup The user is already a member of the group.
@@ -1066,7 +1067,7 @@ api.inviteToGroup = {
let uuids = req.body.uuids;
let emails = req.body.emails;
Group.validateInvitations(uuids, emails, res);
await Group.validateInvitations(uuids, emails, res, group);
let results = [];
+197 -14
View File
@@ -27,8 +27,39 @@ let api = {};
* @apiName UserGet
* @apiGroup User
*
* @apiDescription The user profile contains data related to the authenticated user including (but not limited to);
* Achievements
* Authentications (including types and timestamps)
* Challenges
* Flags (including armoire, tutorial, tour etc...)
* Guilds
* History (including timestamps and values)
* Inbox (includes message history)
* Invitations (to parties/guilds)
* Items (character's full inventory)
* New Messages (flags for groups/guilds that have new messages)
* Notifications
* Party (includes current quest information)
* Preferences (user selected prefs)
* Profile (name, photo url, blurb)
* Purchased (includes purchase history, gem purchased items, plans)
* PushDevices (identifiers for mobile devices authorized)
* Stats (standard RPG stats, class, buffs, xp, etc..)
* Tags
* TasksOrder (list of all ids for dailys, habits, rewards and todos)
*
* @apiSuccess {Object} data The user object
*/
*
* @apiSuccessExample {json} Result:
* {
* "success": true,
* "data": {
* -- User data included here, for details of the user model see:
* -- https://github.com/HabitRPG/habitica/tree/develop/website/server/models/user
* }
* }
*
*/
api.getUser = {
method: 'GET',
middlewares: [authWithHeaders()],
@@ -46,11 +77,30 @@ api.getUser = {
};
/**
* @api {get} /api/v3/user/inventory/buy Get the gear items available for purchase for the current user
* @api {get} /api/v3/user/inventory/buy Get the gear items available for purchase for the authenticated user
* @apiName UserGetBuyList
* @apiGroup User
*
* @apiSuccess {Object} data The buy list
* @apiSuccessExample {json} Success-Response:
* {
* "success": true,
* "data": [
* {
* "text": "Training Sword",
* "notes": "Practice weapon. Confers no benefit.",
* "value": 1,
* "type": "weapon",
* "key": "weapon_warrior_0",
* "set": "warrior-0",
* "klass": "warrior",
* "index": "0",
* "str": 0,
* "int": 0,
* "per": 0,
* "con": 0
* }
* ]
* }
*/
api.getBuyList = {
method: 'GET',
@@ -142,11 +192,31 @@ let checkPreferencePurchase = (user, path, item) => {
/**
* @api {put} /api/v3/user Update the user
* @apiDescription Example body: {'stats.hp':50, 'preferences.background': 'beach'}
* @apiName UserUpdate
* @apiGroup User
*
* @apiSuccess {Object} data The updated user object
* @apiDescription Some of the user items can be updated, such as preferences, flags and stats.
^
* @apiParamExample {json} Request-Example:
* {
* "achievements.habitBirthdays": 2,
* "profile.name": "MadPink",
* "stats.hp": 53,
* "flags.warnedLowHealth":false,
* "preferences.allocationMode":"flat",
* "preferences.hair.bangs": 3
* }
*
* @apiSuccess {Object} data The updated user object, the result is identical to the get user call
*
* @apiError (401) {NotAuthorized} messageUserOperationProtected Returned if the change is not allowed.
*
* @apiErrorExample {json} Error-Response:
* {
* "success": false,
* "error": "NotAuthorized",
* "message": "path `stats.class` was not saved, as it's a protected path."
* }
*/
api.updateUser = {
method: 'PUT',
@@ -179,9 +249,32 @@ api.updateUser = {
* @apiName UserDelete
* @apiGroup User
*
* @apiParam {String} password The user's password (unless it's a Facebook account)
* @apiParam {String} password The user's password if the account uses local authentication
*
* @apiSuccess {Object} data An empty Object
*
* @apiSuccessExample {json} Result:
* {
* "success": true,
* "data": {}
* }
*
* @apiError {BadRequest} MissingPassword The password was not included in the request
* @apiError {BadRequest} NotAuthorized There is no account that uses those credentials.
*
* @apiErrorExample {json}
* {
* "success": false,
* "error": "BadRequest",
* "message": "Invalid request parameters.",
* "errors": [
* {
* "message": "Missing password.",
* "param": "password"
* }
* ]
* }
*
*/
api.deleteUser = {
method: 'DELETE',
@@ -240,8 +333,17 @@ function _cleanChecklist (task) {
* @apiName UserGetAnonymized
* @apiGroup User
*
* @apiDescription Returns the user's data without:
* Authentication information
* NewMessages/Invitations/Inbox
* Profile
* Purchased information
* Contributor information
* Special items
* Webhooks
*
* @apiSuccess {Object} data.user
* @apiSuccess {Array} data.tasks
* @apiSuccess {Object} data.tasks
**/
api.getUserAnonymized = {
method: 'GET',
@@ -306,11 +408,15 @@ const partyMembersFields = 'profile.name stats achievements items.special';
* @apiGroup User
*
* @apiParam {String=fireball, mpHeal, earth, frost, smash, defensiveStance, valorousPresence, intimidate, pickPocket, backStab, toolsOfTrade, stealth, heal, protectAura, brightness, healAll} spellId The skill to cast.
* @apiParam {UUID} targetId Optional query parameter, the id of the target when casting a skill on a party member or a task
* @apiParam (Body) {UUID} targetId Query parameter, necessary if the spell is cast on a party member or task. Not used if the spell is case on onesself or the user's current party.
* @apiParamExample {json} Query example:
* {
* "targetId":"fd427623-9a69-4aac-9852-13deb9c190c3"
* }
*
* @apiSuccess data Will return the modified targets. For party members only the necessary fields will be populated. The user is always returned.
*
* @apiExample Skill Key to Name Mapping
* @apiDescription Skill Key to Name Mapping
* Mage
* fireball: "Burst of Flames"
* mpHeal: "Ethereal Surge"
@@ -335,10 +441,10 @@ const partyMembersFields = 'profile.name stats achievements items.special';
* brightness: "Searing Brightness"
* healAll: "Blessing"
*
* @apiError (400) {NotAuthorized} Not enough mana.
* @apiUse TaskNotFound
* @apiUse PartyNotFound
* @apiUse UserNotFound
*
*/
api.castSpell = {
method: 'POST',
@@ -501,7 +607,15 @@ api.castSpell = {
* @apiName UserSleep
* @apiGroup User
*
* @apiDescription Toggles the sleep key under user preference true and false.
*
* @apiSuccess {boolean} data user.preferences.sleep
*
* @apiSuccessExample {json} Return-example
* {
* "success": true,
* "data": false
* }
*/
api.sleep = {
method: 'POST',
@@ -516,13 +630,25 @@ api.sleep = {
};
/**
* @api {post} /api/v3/user/allocate Allocate an attribute point
* @api {post} /api/v3/user/allocate Allocate a single attribute point
* @apiName UserAllocate
* @apiGroup User
*
* @apiParam {String} stat Query parameter - Defaults to 'str', mast be one of be of str, con, int or per
* @apiParam (Body) {String="str","con","int","per"} stat Query parameter - Default ='str'
*
* @apiSuccess {Object} data user.stats
* @apiParamExample {json} Example request
* {"stat":"int"}
*
* @apiSuccess {Object} data Returns stats from the user profile
*
* @apiError {NotAuthorized} NoPoints Not enough attribute points to increment a stat.
*
* @apiErrorExample {json}
* {
* "success": false,
* "error": "NotAuthorized",
* "message": "You don't have enough attribute points."
* }
*/
api.allocate = {
method: 'POST',
@@ -538,10 +664,46 @@ api.allocate = {
/**
* @api {post} /api/v3/user/allocate-now Allocate all attribute points
* @apiDescription Uses the user's chosen automatic allocation method, or if none, assigns all to STR.
* @apiDescription Uses the user's chosen automatic allocation method, or if none, assigns all to STR. Note: will return success, even if there are 0 points to allocate.
* @apiName UserAllocateNow
* @apiGroup User
*
* @apiSuccessExample {json} Success-Response:
* {
* "success": true,
* "data": {
* "hp": 50,
* "mp": 38,
* "exp": 7,
* "gp": 284.8637271160258,
* "lvl": 10,
* "class": "rogue",
* "points": 0,
* "str": 2,
* "con": 2,
* "int": 3,
* "per": 3,
* "buffs": {
* "str": 0,
* "int": 0,
* "per": 0,
* "con": 0,
* "stealth": 0,
* "streaks": false,
* "snowball": false,
* "spookySparkles": false,
* "shinySeed": false,
* "seafoam": false
* },
* "training": {
* "int": 0,
* "per": 0,
* "str": 0,
* "con": 0
* }
* }
* }
*
* @apiSuccess {Object} data user.stats
*/
api.allocateNow = {
@@ -563,6 +725,27 @@ api.allocateNow = {
* @apiGroup User
*
* @apiParam {String} key The item to buy
*
* @apiSuccess data User's data profile
* @apiSuccess message Item purchased
*
* @apiSuccessExample {json} Purchased a rogue short sword for example:
* {
* "success": true,
* "data": {
* ---TRUNCATED USER RECORD---
* },
* "message": "Bought Short Sword"
* }
*
* @apiError (400) {NotAuthorized} messageAlreadyOwnGear Already own equipment
* @apiError (400) {NotAuthorized} messageNotEnoughGold Not enough gold for the purchase
*
* @apiErrorExample {json} NotAuthorized Already own
* {"success":false,"error":"NotAuthorized","message":"You already own that piece of equipment"}
*
* @apiErrorExample {json} NotAuthorized Not enough gold
* {"success":false,"error":"NotAuthorized","message":"Not Enough Gold"}
*/
api.buy = {
method: 'POST',
+22 -1
View File
@@ -300,7 +300,7 @@ schema.statics.toJSONCleanChat = function groupToJSONCleanChat (group, user) {
* @param res Express res object for use with translations
* @throws BadRequest An error describing the issue with the invitations
*/
schema.statics.validateInvitations = function getInvitationError (uuids, emails, res) {
schema.statics.validateInvitations = async function getInvitationError (uuids, emails, res, group = null) {
let uuidsIsArray = Array.isArray(uuids);
let emailsIsArray = Array.isArray(emails);
let emptyEmails = emailsIsArray && emails.length < 1;
@@ -339,6 +339,27 @@ schema.statics.validateInvitations = function getInvitationError (uuids, emails,
if (totalInvites > INVITES_LIMIT) {
throw new BadRequest(res.t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}));
}
// If party, check the limit of members
if (group && group.type === 'party') {
let memberCount = 0;
// Counting the members that already joined the party
memberCount += group.memberCount;
// Count how many invitations currently exist in the party
let query = {};
query['invitations.party.id'] = group._id;
let groupInvites = await User.count(query).exec();
memberCount += groupInvites;
// Counting the members that are going to be invited by email and uuids
memberCount += totalInvites;
if (memberCount > shared.constants.PARTY_LIMIT_MEMBERS) {
throw new BadRequest(res.t('partyExceedsMembersLimit', {maxMembersParty: shared.constants.PARTY_LIMIT_MEMBERS}));
}
}
};
schema.methods.getParticipatingQuestMembers = function getParticipatingQuestMembers () {
+55 -55
View File
@@ -1,55 +1,55 @@
script(id='partials/options.profile.profile.html', type='text/ng-template')
.container-fluid
.row
.col-md-12(ng-show='!_editing.profile')
button.btn.btn-default(ng-click='_editing.profile = true', ng-show='!_editing.profile')= env.t('edit')
h4=env.t('displayName')
span(ng-show='profile.profile.name') {{profile.profile.name}}
p
small.muted=env.t('displayNameDescription1')
|&nbsp;
a(href='/#/options/settings/settings')=env.t('displayNameDescription2')
|&nbsp;
=env.t('displayNameDescription3')
span.muted(ng-hide='profile.profile.name') -&nbsp;
=env.t('none')
| &nbsp;-
h4=env.t('displayPhoto')
img.img-rendering-auto(ng-show='profile.profile.imageUrl', ng-src='{{profile.profile.imageUrl}}')
span.muted(ng-hide='profile.profile.imageUrl') -&nbsp;
=env.t('none')
| &nbsp;-
h4=env.t('displayBlurb')
markdown(ng-show='profile.profile.blurb', text='profile.profile.blurb')
span.muted(ng-hide='profile.profile.blurb') -&nbsp;
=env.t('none')
| &nbsp;-
//{{profile.profile.blurb | linky:'_blank'}}
.row
.col-md-6
h4=env.t('totalCheckinsTitle')
span {{env.t('totalCheckins', {count: user.loginIncentives})}}
.col-md-6
h4
| {{::getProgressDisplay()}}
.progress
.progress-bar(role='progressbar', aria-valuenow='{{::incentivesProgress()}}', aria-valuemin='0', aria-valuemax='100', style='width: {{::incentivesProgress()}}%;')
span.sr-only {{::incentivesProgress()}}% Complete
form.col-md-4(ng-show='_editing.profile', ng-submit='save()')
.alert.alert-info.alert-sm
!=env.t("communityGuidelinesWarning")
input.btn.btn-primary(type='submit', value=env.t('save'))
// TODO use photo-upload instead: https://groups.google.com/forum/?fromgroups=#!topic/derbyjs/xMmADvxBOak
.form-group
label=env.t('displayName')
input.form-control(type='text', placeholder=env.t('fullName'), ng-model='editingProfile.name')
.form-group
label=env.t('photoUrl')
input.form-control(type='url', ng-model='editingProfile.imageUrl', placeholder=env.t('imageUrl'))
.form-group
label=env.t('displayBlurb')
textarea.form-control(rows=5, placeholder=env.t('displayBlurbPlaceholder'), ng-model='editingProfile.blurb')
include ../../shared/formatting-help
script(id='partials/options.profile.profile.html', type='text/ng-template')
.container-fluid
.row
.col-md-12(ng-show='!_editing.profile')
button.btn.btn-default(ng-click='_editing.profile = true', ng-show='!_editing.profile')= env.t('edit')
h4=env.t('displayName')
span(ng-show='profile.profile.name') {{profile.profile.name}}
p
small.muted=env.t('displayNameDescription1')
|&nbsp;
a(href='/#/options/settings/settings')=env.t('displayNameDescription2')
|&nbsp;
=env.t('displayNameDescription3')
span.muted(ng-hide='profile.profile.name') -&nbsp;
=env.t('none')
| &nbsp;-
h4=env.t('displayPhoto')
img.img-rendering-auto(ng-show='profile.profile.imageUrl', ng-src='{{profile.profile.imageUrl}}')
span.muted(ng-hide='profile.profile.imageUrl') -&nbsp;
=env.t('none')
| &nbsp;-
h4=env.t('displayBlurb')
markdown(ng-show='profile.profile.blurb', text='profile.profile.blurb')
span.muted(ng-hide='profile.profile.blurb') -&nbsp;
=env.t('none')
| &nbsp;-
//{{profile.profile.blurb | linky:'_blank'}}
.row
.col-md-6
h4=env.t('totalCheckinsTitle')
span {{env.t('totalCheckins', {count: user.loginIncentives})}}
.col-md-6
h4
| {{::getProgressDisplay()}}
.progress
.progress-bar(role='progressbar', aria-valuenow='{{::incentivesProgress()}}', aria-valuemin='0', aria-valuemax='100', style='width: {{::incentivesProgress()}}%;')
span.sr-only {{::incentivesProgress()}}% Complete
form.col-md-4(ng-show='_editing.profile', ng-submit='save()')
.alert.alert-info.alert-sm
!=env.t("communityGuidelinesWarning", { hrefBlankCommunityManagerEmail : '<a href="mailto:' + env.EMAILS.COMMUNITY_MANAGER_EMAIL + '">' + env.EMAILS.COMMUNITY_MANAGER_EMAIL + '</a>'})
input.btn.btn-primary(type='submit', value=env.t('save'))
// TODO use photo-upload instead: https://groups.google.com/forum/?fromgroups=#!topic/derbyjs/xMmADvxBOak
.form-group
label=env.t('displayName')
input.form-control(type='text', placeholder=env.t('fullName'), ng-model='editingProfile.name')
.form-group
label=env.t('photoUrl')
input.form-control(type='url', ng-model='editingProfile.imageUrl', placeholder=env.t('imageUrl'))
.form-group
label=env.t('displayBlurb')
textarea.form-control(rows=5, placeholder=env.t('displayBlurbPlaceholder'), ng-model='editingProfile.blurb')
include ../../shared/formatting-help
+57 -57
View File
@@ -1,57 +1,57 @@
script(type='text/ng-template', id='partials/options.settings.api.html')
.container-fluid
.row
.col-md-6
h2=env.t('API')
small=env.t('APIText')
h6=env.t('userId')
pre.prettyprint {{user.id}}
h6=env.t('APIToken')
pre.prettyprint {{User.settings.auth.apiToken}}
small!=env.t("APITokenWarning")
br
h3=env.t('thirdPartyApps')
ul
li
a(target='_blank' href='https://www.beeminder.com/habitica')=env.t('beeminder')
br
=env.t('beeminderDesc')
li
a(target='_blank' href='https://chrome.google.com/webstore/detail/habitrpg-chat-client/hidkdfgonpoaiannijofifhjidbnilbb')=env.t('chromeChatExtension')
br
=env.t('chromeChatExtensionDesc')
li
a(target='_blank' ng-href='http://data.habitrpg.com?uuid={{user._id}}')=env.t('dataTool')
br
=env.t('dataToolDesc')
li
!=env.t('otherExtensions')
br
=env.t('otherDesc')
hr
h2=env.t('webhooks')
table.table.table-striped
thead(ng-if='user.webhooks.length')
tr
th=env.t('enabled')
th=env.t('webhookURL')
th
tbody
tr(ng-repeat="webhook in user.webhooks track by $index")
td
input(type='checkbox', ng-model='webhook.enabled', ng-change='saveWebhook(webhook, $index)')
td
input.form-control(type='url', ng-model='webhook.url', ng-change='webhook._editing=true', ui-keyup="{13:'saveWebhook(webhook, $index)'}")
td
span.pull-left(ng-show='webhook._editing') *
a.checklist-icons(ng-click='deleteWebhook(webhook, $index)')
span.glyphicon.glyphicon-trash(tooltip=env.t('delete'))
tr
td(colspan=2)
form.form-horizontal(ng-submit='addWebhook(_newWebhook.url)')
.form-group.col-sm-10
input.form-control(type='url', ng-model='_newWebhook.url', placeholder=env.t('webhookURL'))
.col-sm-2
button.btn.btn-sm.btn-primary(type='submit')=env.t('add')
script(type='text/ng-template', id='partials/options.settings.api.html')
.container-fluid
.row
.col-md-6
h2=env.t('API')
small=env.t('APIText')
h6=env.t('userId')
pre.prettyprint {{user.id}}
h6=env.t('APIToken')
pre.prettyprint {{User.settings.auth.apiToken}}
small!=env.t("APITokenWarning", { hrefTechAssistanceEmail : '<a href="mailto:' + env.EMAILS.TECH_ASSISTANCE_EMAIL + '">' + env.EMAILS.TECH_ASSISTANCE_EMAIL + '</a>' })
br
h3=env.t('thirdPartyApps')
ul
li
a(target='_blank' href='https://www.beeminder.com/habitica')=env.t('beeminder')
br
=env.t('beeminderDesc')
li
a(target='_blank' href='https://chrome.google.com/webstore/detail/habitrpg-chat-client/hidkdfgonpoaiannijofifhjidbnilbb')=env.t('chromeChatExtension')
br
=env.t('chromeChatExtensionDesc')
li
a(target='_blank' ng-href='http://data.habitrpg.com?uuid={{user._id}}')=env.t('dataTool')
br
=env.t('dataToolDesc')
li
!=env.t('otherExtensions')
br
=env.t('otherDesc')
hr
h2=env.t('webhooks')
table.table.table-striped
thead(ng-if='user.webhooks.length')
tr
th=env.t('enabled')
th=env.t('webhookURL')
th
tbody
tr(ng-repeat="webhook in user.webhooks track by $index")
td
input(type='checkbox', ng-model='webhook.enabled', ng-change='saveWebhook(webhook, $index)')
td
input.form-control(type='url', ng-model='webhook.url', ng-change='webhook._editing=true', ui-keyup="{13:'saveWebhook(webhook, $index)'}")
td
span.pull-left(ng-show='webhook._editing') *
a.checklist-icons(ng-click='deleteWebhook(webhook, $index)')
span.glyphicon.glyphicon-trash(tooltip=env.t('delete'))
tr
td(colspan=2)
form.form-horizontal(ng-submit='addWebhook(_newWebhook.url)')
.form-group.col-sm-10
input.form-control(type='url', ng-model='_newWebhook.url', placeholder=env.t('webhookURL'))
.col-sm-2
button.btn.btn-sm.btn-primary(type='submit')=env.t('add')
+3 -1
View File
@@ -87,7 +87,9 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter
h3.panel-title
=env.t('members')
span(ng-if='group.type=="party" && (group.onlineUsers || group.onlineUsers == 0)')= ' (' + env.t('onlineCount', {count: "{{group.onlineUsers}}"}) + ')'
button.pull-right.btn.btn-primary(ng-click="inviteOrStartParty(group)")=env.t("inviteFriends")
button.pull-right.btn.btn-primary(ng-if='group.type=="party" ? (group.memberCount + group.invites.length < PARTY_LIMIT_MEMBERS) : true' ng-click="inviteOrStartParty(group)")=env.t("inviteFriends")
.panel-heading(ng-if='group.type=="party"')
p=env.t('partyMembersInfo', {memberCount: "{{group.memberCount}}", invitationCount:"{{group.invites.length}}", limitMembers:"{{PARTY_LIMIT_MEMBERS}}"})
.panel-body.modal-fixed-height
h4(ng-show='::group.memberCount === 1 && group.type === "party"')=env.t('partyEmpty')
+2
View File
@@ -36,6 +36,8 @@ footer.footer(ng-controller='FooterCtrl')
a(href='/static/privacy')=env.t('companyPrivacy')
li
a(href='/static/terms')=env.t('companyTerms')
li
a(href='/static/press-kit')=env.t('presskit')
li
a(href='/static/contact')=env.t('contactUs')
li
+1
View File
@@ -26,6 +26,7 @@ include ./login-incentives-reward-unlocked.jade
include ./generic.jade
include ./tasks-edit.jade
include ./task-notes.jade
include ./task-extra-notes.jade
//- Settings
script(type='text/ng-template', id='modals/change-day-start.html')
+3
View File
@@ -79,6 +79,9 @@ script(type='text/ng-template', id='modals/send-gift.html')
.btn-group
a.btn.btn-default(ng-class="{active:gift.gems.fromBalance}", ng-click="gift.gems.fromBalance=true")=env.t('sendGiftFromBalance')
a.btn.btn-default(ng-class="{active:!gift.gems.fromBalance}", ng-click="gift.gems.fromBalance=false")=env.t('sendGiftPurchase')
.row
.col-md-12
p.small.muted!=env.t('gemGiftsAreOptional', { hrefTechAssistanceEmail : '<a href="mailto:' + env.EMAILS.TECH_ASSISTANCE_EMAIL + '">' + env.EMAILS.TECH_ASSISTANCE_EMAIL + '</a>'})
.panel.panel-default(class="{{gift.type=='subscription' ? 'panel-primary' : 'transparent'}}", ng-click='gift.type="subscription"')
.panel-heading=env.t('subscription')
@@ -0,0 +1,10 @@
script(id='modals/task-extra-notes.html', type='text/ng-template')
.modal-header
h4=env.t('taskNotes')
.modal-body
div
markdown(text='task.notes')
.modal-footer
.btn.btn-primary(ng-click="$close()")=env.t('close')
@@ -4,14 +4,14 @@
span(ng-if='showDoubleTaskCounter(task, obj)')
span(tooltip=env.t('habitCounterUp')) +{{task.counterUp}}|
span(tooltip=env.t('habitCounterDown')) -{{task.counterDown}}&nbsp;
span(ng-if='showSingleTaskCounter(task, obj)')
span(tooltip=env.t('habitCounter')) {{task.up ? task.counterUp : task.counterDown}}&nbsp;
// Due Date
span(ng-if='task.type=="todo" && task.date')
span(ng-class='{"label label-danger":(moment(task.date).isBefore(_today, "days") && !task.completed)}') {{task.date | date:(user.preferences.dateFormat.indexOf('yyyy') == 0 ? user.preferences.dateFormat.substr(5) : user.preferences.dateFormat.substr(0,5))}}
// Approval requested
| &nbsp;
span(ng-show='task.group.approval.requested && !task.group.approval.approved')
@@ -29,7 +29,7 @@
// Icons only available if you own the tasks (aka, hidden from challenge stats)
span(ng-if='!obj._locked')
group-task-meta-actions(ng-if="!obj.auth && obj.purchased && obj.purchased.active", task='task', group='obj')
a(ng-click='pushTask(task,$index,"top")', tooltip=env.t('pushTaskToTop'), ng-class="{'push-down': ctrlPressed}")
span(ng-hide="ctrlPressed").glyphicon.glyphicon-open
span(ng-show="ctrlPressed").glyphicon.glyphicon-save
@@ -39,13 +39,13 @@
a.badge(ng-if='task.checklist[0]', ng-class='{"badge-success":checklistCompletion(task.checklist) == task.checklist.length}', ng-click='collapseChecklist(task)', tooltip=env.t('expandCollapse'))
|{{checklistCompletion(task.checklist)}}/{{task.checklist.length}}
span.glyphicon.glyphicon-tags(tooltip='{{Shared.appliedTags(user.tags, task.tags)}}', ng-hide='Shared.noTags(task.tags)')
// edit
a(ng-hide='checkGroupAccess && !checkGroupAccess(obj)', ng-click='editTask(task, user, Shared.taskClasses(task, user.filters, user.preferences.dayStart, user.lastCron, list.showCompleted, main))', tooltip=env.t('edit'))
| &nbsp;
span.glyphicon.glyphicon-pencil
| &nbsp;
//challenges
span(ng-if='task.challenge.id')
span(ng-if='task.challenge.broken')
@@ -54,7 +54,7 @@
span(ng-if='!task.challenge.broken')
span.glyphicon.glyphicon-bullhorn(tooltip=env.t('challenge'))
| &nbsp;
// delete
a(ng-if='!task.challenge.id || (obj.leader && obj.leader.id === user._id)', ng-hide="(checkGroupAccess && !checkGroupAccess(obj))" ng-click='removeTask(task, obj)', tooltip=env.t('delete'))
span.glyphicon.glyphicon-trash
@@ -67,6 +67,6 @@
// notes
// Make this icon available regardless of task ownership
a.task-notes(ng-show='task.notes && !task._editing', ng-click='task.popoverOpen = !task.popoverOpen', popover-trigger='click', data-popover-html="{{task.notes | markdown}}", popover-placement="top", popover-append-to-body='{{::modal ? "false":"true"}}')
a.task-notes(ng-show='task.notes && !task._editing', ng-click='showNoteDetails(task);', popover-trigger='hover', data-popover-html="{{task.notes | markdown}}", popover-placement="top", popover-append-to-body='{{::modal ? "false":"true"}}')
span.glyphicon.glyphicon-comment
| &nbsp;
@@ -28,8 +28,8 @@ block content
p.pagemeta
=env.t('lastUpdated')
|&nbsp;
=env.t('February')
|&nbsp;28&comma; 2016
=env.t('March')
|&nbsp;30&comma; 2017
h2#welcome=env.t('commGuideHeadingWelcome')
.clearfix
img.pull-left(src='/community-guidelines-images/intro.png', alt='')