* feat: Add alias property to task model
This commit is contained in:
Blade Barringer
2016-06-16 12:28:45 -05:00
committed by GitHub
parent c34c21192b
commit b7b61e6251
21 changed files with 536 additions and 97 deletions

View File

@@ -4,6 +4,7 @@
"habitrpg/babel" "habitrpg/babel"
], ],
"globals": { "globals": {
"Promise": true "Promise": true,
"Set": false
} }
} }

View File

@@ -20,6 +20,10 @@
"extraNotes": "Extra Notes", "extraNotes": "Extra Notes",
"direction/Actions": "Direction/Actions", "direction/Actions": "Direction/Actions",
"advancedOptions": "Advanced Options", "advancedOptions": "Advanced Options",
"taskAlias": "Task Alias",
"taskAliasPopover": "This task alias can be used when integrating with 3rd party integrations. Only dashes, underscores, and alphanumeric characters are supported. The task alias must be unique among all your tasks.",
"taskAliasPlaceholder": "your-task-alias-here",
"taskAliasPopoverWarning": "WARNING: Changing this value will break any 3rd party integrations that rely on the task alias.",
"difficulty": "Difficulty", "difficulty": "Difficulty",
"difficultyHelpTitle": "How difficult is this task?", "difficultyHelpTitle": "How difficult is this task?",
"difficultyHelpContent": "The harder a task, the more Experience and Gold it awards you when you check it off... but the more it damages you if it is a Daily or Bad Habit!", "difficultyHelpContent": "The harder a task, the more Experience and Gold it awards you when you check it off... but the more it damages you if it is a Daily or Bad Habit!",
@@ -113,6 +117,7 @@
"rewardHelp4": "Don't be afraid to set custom Rewards! Check out <a href='http://habitica.wikia.com/wiki/Sample_Custom_Rewards' target='_blank'>some samples here</a>.", "rewardHelp4": "Don't be afraid to set custom Rewards! Check out <a href='http://habitica.wikia.com/wiki/Sample_Custom_Rewards' target='_blank'>some samples here</a>.",
"clickForHelp": "Click for help", "clickForHelp": "Click for help",
"taskIdRequired": "\"taskId\" must be a valid UUID.", "taskIdRequired": "\"taskId\" must be a valid UUID.",
"taskAliasAlreadyUsed": "Task alias already used on another task.",
"taskNotFound": "Task not found.", "taskNotFound": "Task not found.",
"invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".", "invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".",
"cantDeleteChallengeTasks": "A task belonging to a challenge can't be deleted.", "cantDeleteChallengeTasks": "A task belonging to a challenge can't be deleted.",

View File

@@ -6,7 +6,7 @@ import {
describe('DELETE /tasks/:id', () => { describe('DELETE /tasks/:id', () => {
let user; let user;
before(async () => { beforeEach(async () => {
user = await generateUser(); user = await generateUser();
}); });
@@ -17,6 +17,7 @@ describe('DELETE /tasks/:id', () => {
task = await user.post('/tasks/user', { task = await user.post('/tasks/user', {
text: 'test habit', text: 'test habit',
type: 'habit', type: 'habit',
alias: 'task-to-be-deleted',
}); });
}); });
@@ -29,6 +30,16 @@ describe('DELETE /tasks/:id', () => {
message: t('taskNotFound'), message: t('taskNotFound'),
}); });
}); });
it('can use a alias to delete a task', async () => {
await user.del(`/tasks/${task.alias}`);
await expect(user.get(`/tasks/${task._id}`)).to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('taskNotFound'),
});
});
}); });
context('task cannot be deleted', () => { context('task cannot be deleted', () => {

View File

@@ -7,7 +7,7 @@ import { v4 as generateUUID } from 'uuid';
describe('GET /tasks/:id', () => { describe('GET /tasks/:id', () => {
let user; let user;
before(async () => { beforeEach(async () => {
user = await generateUser(); user = await generateUser();
}); });
@@ -18,11 +18,19 @@ describe('GET /tasks/:id', () => {
task = await user.post('/tasks/user', { task = await user.post('/tasks/user', {
text: 'test habit', text: 'test habit',
type: 'habit', type: 'habit',
alias: 'alias',
}); });
}); });
it('gets specified task', async () => { it('gets specified task', async () => {
let getTask = await user.get(`/tasks/${task._id}`); let getTask = await user.get(`/tasks/${task._id}`);
expect(getTask).to.eql(task);
});
it('can use alias to retrieve task', async () => {
let getTask = await user.get(`/tasks/${task.alias}`);
expect(getTask).to.eql(task); expect(getTask).to.eql(task);
}); });

View File

@@ -14,12 +14,28 @@ describe('POST /tasks/:id/score/:direction', () => {
}); });
context('all', () => { context('all', () => {
it('requires a task id', async () => { it('can use an id to identify the task', async () => {
await expect(user.post('/tasks/123/score/up')).to.eventually.be.rejected.and.eql({ let todo = await user.post('/tasks/user', {
code: 400, text: 'test todo',
error: 'BadRequest', type: 'todo',
message: t('invalidReqParams'), alias: 'alias',
}); });
let res = await user.post(`/tasks/${todo._id}/score/up`);
expect(res).to.be.ok;
});
it('can use a alias in place of the id', async () => {
let todo = await user.post('/tasks/user', {
text: 'test todo',
type: 'todo',
alias: 'alias',
});
let res = await user.post(`/tasks/${todo.alias}/score/up`);
expect(res).to.be.ok;
}); });
it('requires a task direction', async () => { it('requires a task direction', async () => {

View File

@@ -11,14 +11,6 @@ describe('POST /tasks/:taskId/move/to/:position', () => {
user = await generateUser(); user = await generateUser();
}); });
it('requires a valid taskId', async () => {
await expect(user.post('/tasks/123/move/to/1')).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('requires a numeric position parameter', async () => { it('requires a numeric position parameter', async () => {
await expect(user.post(`/tasks/${generateUUID()}/move/to/notANumber`)).to.eventually.be.rejected.and.eql({ await expect(user.post(`/tasks/${generateUUID()}/move/to/notANumber`)).to.eventually.be.rejected.and.eql({
code: 400, code: 400,
@@ -53,6 +45,24 @@ describe('POST /tasks/:taskId/move/to/:position', () => {
expect(newOrder.length).to.equal(5); expect(newOrder.length).to.equal(5);
}); });
it('can move task to new position using alias', async () => {
let tasks = await user.post('/tasks/user', [
{type: 'habit', text: 'habit 1'},
{type: 'habit', text: 'habit 2', alias: 'move'},
{type: 'daily', text: 'daily 1'},
{type: 'habit', text: 'habit 3'},
{type: 'habit', text: 'habit 4'},
{type: 'todo', text: 'todo 1'},
{type: 'habit', text: 'habit 5'},
]);
let taskToMove = tasks[1];
expect(taskToMove.text).to.equal('habit 2');
let newOrder = await user.post(`/tasks/${taskToMove.alias}/move/to/3`);
expect(newOrder[3]).to.equal(taskToMove._id);
expect(newOrder.length).to.equal(5);
});
it('can\'t move completed todo', async () => { it('can\'t move completed todo', async () => {
let task = await user.post('/tasks/user', {type: 'todo', text: 'todo 1'}); let task = await user.post('/tasks/user', {type: 'todo', text: 'todo 1'});
await user.post(`/tasks/${task._id}/score/up`); await user.post(`/tasks/${task._id}/score/up`);

View File

@@ -57,7 +57,7 @@ describe('POST /tasks/user', () => {
let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits; let originalHabitsOrder = (await user.get('/user')).tasksOrder.habits;
await expect(user.post('/tasks/user', { await expect(user.post('/tasks/user', {
type: 'habit', type: 'habit',
})).to.eventually.be.rejected.and.eql({ // this block is necessary })).to.eventually.be.rejected.and.eql({
code: 400, code: 400,
error: 'BadRequest', error: 'BadRequest',
message: 'habit validation failed', message: 'habit validation failed',
@@ -72,7 +72,7 @@ describe('POST /tasks/user', () => {
await expect(user.post('/tasks/user', [ await expect(user.post('/tasks/user', [
{type: 'habit'}, // Missing text {type: 'habit'}, // Missing text
{type: 'habit', text: 'valid'}, // Valid {type: 'habit', text: 'valid'}, // Valid
])).to.eventually.be.rejected.and.eql({ // this block is necessary ])).to.eventually.be.rejected.and.eql({
code: 400, code: 400,
error: 'BadRequest', error: 'BadRequest',
message: 'habit validation failed', message: 'habit validation failed',
@@ -87,7 +87,7 @@ describe('POST /tasks/user', () => {
await expect(user.post('/tasks/user', [ await expect(user.post('/tasks/user', [
{type: 'habit'}, // Missing text {type: 'habit'}, // Missing text
{type: 'habit', text: 'valid'}, // Valid {type: 'habit', text: 'valid'}, // Valid
])).to.eventually.be.rejected.and.eql({ // this block is necessary ])).to.eventually.be.rejected.and.eql({
code: 400, code: 400,
error: 'BadRequest', error: 'BadRequest',
message: 'habit validation failed', message: 'habit validation failed',
@@ -142,6 +142,67 @@ describe('POST /tasks/user', () => {
expect(task).not.to.have.property('notValid'); expect(task).not.to.have.property('notValid');
}); });
it('errors if alias already exists on another task', async () => {
await user.post('/tasks/user', { // first task that will succeed
type: 'habit',
text: 'todo text',
alias: 'alias',
});
await expect(user.post('/tasks/user', {
type: 'todo',
text: 'todo text',
alias: 'alias',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'todo validation failed',
});
});
it('errors if alias contains invalid values', async () => {
await expect(user.post('/tasks/user', {
type: 'todo',
text: 'todo text',
alias: 'short name!',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'todo validation failed',
});
});
it('errors if alias is a valid uuid', async () => {
await expect(user.post('/tasks/user', {
type: 'todo',
text: 'todo text',
alias: generateUUID(),
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'todo validation failed',
});
});
it('errors if the same shortname is used on 2 or more tasks', async () => {
await expect(user.post('/tasks/user', [{
type: 'habit',
text: 'habit text',
alias: 'alias',
}, {
type: 'todo',
text: 'todo text',
}, {
type: 'todo',
text: 'todo text',
alias: 'alias',
}])).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('taskAliasAlreadyUsed'),
});
});
}); });
context('all types', () => { context('all types', () => {
@@ -163,6 +224,16 @@ describe('POST /tasks/user', () => {
expect(task.reminders[0].startDate).to.be.a('string'); // json doesn't have dates expect(task.reminders[0].startDate).to.be.a('string'); // json doesn't have dates
expect(task.reminders[0].time).to.be.a('string'); expect(task.reminders[0].time).to.be.a('string');
}); });
it('can create a task with a alias', async () => {
let task = await user.post('/tasks/user', {
text: 'test habit',
type: 'habit',
alias: 'a_alias012',
});
expect(task.alias).to.eql('a_alias012');
});
}); });
context('habits', () => { context('habits', () => {

View File

@@ -9,7 +9,7 @@ import { v4 as generateUUID } from 'uuid';
describe('PUT /tasks/:id', () => { describe('PUT /tasks/:id', () => {
let user; let user;
before(async () => { beforeEach(async () => {
user = await generateUser(); user = await generateUser();
}); });
@@ -59,7 +59,7 @@ describe('PUT /tasks/:id', () => {
expect(savedTask.notValid).to.be.undefined; expect(savedTask.notValid).to.be.undefined;
}); });
it(`only allows setting streak, reminders, checklist, notes, attribute, tags it(`only allows setting streak, alias, reminders, checklist, notes, attribute, tags
fields for challenge tasks owned by a user`, async () => { fields for challenge tasks owned by a user`, async () => {
let guild = await generateGroup(user); let guild = await generateGroup(user);
let challenge = await generateChallenge(user, guild); let challenge = await generateChallenge(user, guild);
@@ -87,6 +87,7 @@ describe('PUT /tasks/:id', () => {
_id: 123, _id: 123,
type: 'daily', type: 'daily',
userId: 123, userId: 123,
alias: 'a-short-task-name',
history: [123], history: [123],
createdAt: 'yesterday', createdAt: 'yesterday',
updatedAt: 'tomorrow', updatedAt: 'tomorrow',
@@ -176,6 +177,44 @@ describe('PUT /tasks/:id', () => {
expect(savedDaily.reminders[0].id).to.equal(id1); expect(savedDaily.reminders[0].id).to.equal(id1);
expect(savedDaily.reminders[1].id).to.equal(id2); expect(savedDaily.reminders[1].id).to.equal(id2);
}); });
it('can set a alias if no other task has that alias', async () => {
let savedDaily = await user.put(`/tasks/${daily._id}`, {
alias: 'alias',
});
expect(savedDaily.alias).to.eql('alias');
});
it('does not set alias to a alias that is already in use', async () => {
await user.post('/tasks/user', {
type: 'todo',
text: 'a todo',
alias: 'some-alias',
});
await expect(user.put(`/tasks/${daily._id}`, {
alias: 'some-alias',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'daily validation failed',
});
});
it('can use alias to update a task', async () => {
daily = await user.put(`/tasks/${daily._id}`, {
alias: 'alias',
});
await user.put(`/tasks/${daily.alias}`, {
text: 'saved',
});
let fetchedDaily = await user.get(`/tasks/${daily._id}`);
expect(fetchedDaily.text).to.eql('saved');
});
}); });
context('habits', () => { context('habits', () => {

View File

@@ -49,6 +49,18 @@ describe('POST /tasks/challenge/:challengeId', () => {
}); });
}); });
it('returns error when user tries to create task with a alias', async () => {
await expect(user.post(`/tasks/challenge/${challenge._id}`, {
text: 'test habit',
type: 'habit',
alias: 'a-alias',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'habit validation failed',
});
});
it('returns error when non leader tries to edit challenge', async () => { it('returns error when non leader tries to edit challenge', async () => {
let userThatIsNotLeaderOfChallenge = await generateUser({ let userThatIsNotLeaderOfChallenge = await generateUser({
challenges: [challenge._id], challenges: [challenge._id],

View File

@@ -3,7 +3,7 @@ import {
generateGroup, generateGroup,
generateChallenge, generateChallenge,
translate as t, translate as t,
} from '../../../../helpers/api-integration/v3'; } from '../../../../../helpers/api-integration/v3';
import { v4 as generateUUID } from 'uuid'; import { v4 as generateUUID } from 'uuid';
describe('PUT /tasks/:id', () => { describe('PUT /tasks/:id', () => {
@@ -54,6 +54,16 @@ describe('PUT /tasks/:id', () => {
message: t('onlyChalLeaderEditTasks'), message: t('onlyChalLeaderEditTasks'),
}); });
}); });
it('returns error when user attempts to update task with a alias', async () => {
await expect(user.put(`/tasks/${task._id}`, {
alias: 'a-alias',
})).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: 'habit validation failed',
});
});
}); });
context('validates params', () => { context('validates params', () => {

View File

@@ -25,6 +25,21 @@ describe('DELETE /tasks/:taskId/checklist/:itemId', () => {
expect(savedTask.checklist.length).to.equal(0); expect(savedTask.checklist.length).to.equal(0);
}); });
it('deletes a checklist item using task alias', async () => {
let task = await user.post('/tasks/user', {
type: 'daily',
text: 'Daily with checklist',
alias: 'daily-with-alias',
});
let savedTask = await user.post(`/tasks/${task._id}/checklist`, {text: 'Checklist Item 1', completed: false});
await user.del(`/tasks/${task.alias}/checklist/${savedTask.checklist[0].id}`);
savedTask = await user.get(`/tasks/${task._id}`);
expect(savedTask.checklist.length).to.equal(0);
});
it('does not work with habits', async () => { it('does not work with habits', async () => {
let habit = await user.post('/tasks/user', { let habit = await user.post('/tasks/user', {
type: 'habit', type: 'habit',

View File

@@ -31,6 +31,27 @@ describe('POST /tasks/:taskId/checklist/', () => {
expect(savedTask.checklist[0].ignored).to.be.an('undefined'); expect(savedTask.checklist[0].ignored).to.be.an('undefined');
}); });
it('can use a alias to add checklist', async () => {
let task = await user.post('/tasks/user', {
type: 'daily',
text: 'Daily with checklist',
alias: 'task-with-shortname',
});
let savedTask = await user.post(`/tasks/${task.alias}/checklist`, {
text: 'Checklist Item 1',
ignored: false,
_id: 123,
});
expect(savedTask.checklist.length).to.equal(1);
expect(savedTask.checklist[0].text).to.equal('Checklist Item 1');
expect(savedTask.checklist[0].completed).to.equal(false);
expect(savedTask.checklist[0].id).to.be.a('string');
expect(savedTask.checklist[0].id).to.not.equal('123');
expect(savedTask.checklist[0].ignored).to.be.an('undefined');
});
it('does not add a checklist to habits', async () => { it('does not add a checklist to habits', async () => {
let habit = await user.post('/tasks/user', { let habit = await user.post('/tasks/user', {
type: 'habit', type: 'habit',

View File

@@ -28,6 +28,24 @@ describe('POST /tasks/:taskId/checklist/:itemId/score', () => {
expect(savedTask.checklist[0].completed).to.equal(true); expect(savedTask.checklist[0].completed).to.equal(true);
}); });
it('can use a alias to score a checklist item', async () => {
let task = await user.post('/tasks/user', {
type: 'daily',
text: 'Daily with checklist',
alias: 'daily-with-shortname',
});
let savedTask = await user.post(`/tasks/${task._id}/checklist`, {
text: 'Checklist Item 1',
completed: false,
});
savedTask = await user.post(`/tasks/${task.alias}/checklist/${savedTask.checklist[0].id}/score`);
expect(savedTask.checklist.length).to.equal(1);
expect(savedTask.checklist[0].completed).to.equal(true);
});
it('fails on habits', async () => { it('fails on habits', async () => {
let habit = await user.post('/tasks/user', { let habit = await user.post('/tasks/user', {
type: 'habit', type: 'habit',

View File

@@ -34,6 +34,30 @@ describe('PUT /tasks/:taskId/checklist/:itemId', () => {
expect(savedTask.checklist[0].id).to.not.equal('123'); expect(savedTask.checklist[0].id).to.not.equal('123');
}); });
it('updates a checklist item using task alias', async () => {
let task = await user.post('/tasks/user', {
type: 'daily',
text: 'Daily with checklist',
alias: 'daily-with-shortname',
});
let savedTask = await user.post(`/tasks/${task._id}/checklist`, {
text: 'Checklist Item 1',
completed: false,
});
savedTask = await user.put(`/tasks/${task.alias}/checklist/${savedTask.checklist[0].id}`, {
text: 'updated',
completed: true,
_id: 123, // ignored
});
expect(savedTask.checklist.length).to.equal(1);
expect(savedTask.checklist[0].text).to.equal('updated');
expect(savedTask.checklist[0].completed).to.equal(true);
expect(savedTask.checklist[0].id).to.not.equal('123');
});
it('fails on habits', async () => { it('fails on habits', async () => {
let habit = await user.post('/tasks/user', { let habit = await user.post('/tasks/user', {
type: 'habit', type: 'habit',

View File

@@ -27,6 +27,23 @@ describe('DELETE /tasks/:taskId/tags/:tagId', () => {
expect(updatedTask.tags.length).to.equal(0); expect(updatedTask.tags.length).to.equal(0);
}); });
it('removes a tag from a task using task short name', async () => {
let task = await user.post('/tasks/user', {
type: 'habit',
text: 'Task with tag',
alias: 'habit-with-alias',
});
let tag = await user.post('/tags', {name: 'Tag 1'});
await user.post(`/tasks/${task._id}/tags/${tag.id}`);
await user.del(`/tasks/${task.alias}/tags/${tag.id}`);
let updatedTask = await user.get(`/tasks/${task._id}`);
expect(updatedTask.tags.length).to.equal(0);
});
it('only deletes existing tags', async () => { it('only deletes existing tags', async () => {
let createdTask = await user.post('/tasks/user', { let createdTask = await user.post('/tasks/user', {
type: 'habit', type: 'habit',

View File

@@ -23,6 +23,19 @@ describe('POST /tasks/:taskId/tags/:tagId', () => {
expect(savedTask.tags[0]).to.equal(tag.id); expect(savedTask.tags[0]).to.equal(tag.id);
}); });
it('adds a tag to a task with alias', async () => {
let task = await user.post('/tasks/user', {
type: 'habit',
text: 'Task with tag',
alias: 'habit-with-alias',
});
let tag = await user.post('/tags', {name: 'Tag 1'});
let savedTask = await user.post(`/tasks/${task.alias}/tags/${tag.id}`);
expect(savedTask.tags[0]).to.equal(tag.id);
});
it('does not add a tag to a task twice', async () => { it('does not add a tag to a task twice', async () => {
let task = await user.post('/tasks/user', { let task = await user.post('/tasks/user', {
type: 'habit', type: 'habit',

View File

@@ -2,6 +2,7 @@ import { model as Challenge } from '../../../../../website/server/models/challen
import { model as Group } from '../../../../../website/server/models/group'; import { model as Group } from '../../../../../website/server/models/group';
import { model as User } from '../../../../../website/server/models/user'; import { model as User } from '../../../../../website/server/models/user';
import * as Tasks from '../../../../../website/server/models/task'; import * as Tasks from '../../../../../website/server/models/task';
import { InternalServerError } from '../../../../../website/server/libs/api-v3/errors';
import { each } from 'lodash'; import { each } from 'lodash';
import { generateHistory } from '../../../../helpers/api-unit.helper.js'; import { generateHistory } from '../../../../helpers/api-unit.helper.js';
@@ -71,4 +72,105 @@ describe('Task Model', () => {
}); });
}); });
}); });
describe('Static Methods', () => {
describe('findByIdOrAlias', () => {
let taskWithAlias, user;
beforeEach(async () => {
user = new User();
await user.save();
taskWithAlias = new Tasks.todo({ // eslint-disable-line babel/new-cap
text: 'some text',
alias: 'short-name',
userId: user.id,
});
await taskWithAlias.save();
sandbox.spy(Tasks.Task, 'findOne');
});
it('throws an error if task identifier is not passed in', async (done) => {
try {
await Tasks.Task.findByIdOrAlias(null, user._id);
} catch (err) {
expect(err).to.exist;
expect(err).to.eql(new InternalServerError('Task identifier is a required argument'));
done();
}
});
it('throws an error if user identifier is not passed in', async (done) => {
try {
await Tasks.Task.findByIdOrAlias(taskWithAlias._id);
} catch (err) {
expect(err).to.exist;
expect(err).to.eql(new InternalServerError('User identifier is a required argument'));
done();
}
});
it('returns task by id', async () => {
let foundTodo = await Tasks.Task.findByIdOrAlias(taskWithAlias._id, user._id);
expect(foundTodo.text).to.eql(taskWithAlias.text);
});
it('returns task by alias', async () => {
let foundTodo = await Tasks.Task.findByIdOrAlias(taskWithAlias.alias, user._id);
expect(foundTodo.text).to.eql(taskWithAlias.text);
});
it('scopes alias lookup to user', async () => {
await Tasks.Task.findByIdOrAlias(taskWithAlias.alias, user._id);
expect(Tasks.Task.findOne).to.be.calledOnce;
expect(Tasks.Task.findOne).to.be.calledWithMatch({
alias: taskWithAlias.alias,
userId: user._id,
});
});
it('returns null if task cannot be found', async () => {
let foundTask = await Tasks.Task.findByIdOrAlias('not-found', user._id);
expect(foundTask).to.eql(null);
});
it('accepts additional query parameters', async () => {
await Tasks.Task.findByIdOrAlias(taskWithAlias.alias, user._id, { foo: 'bar' });
expect(Tasks.Task.findOne).to.be.calledOnce;
expect(Tasks.Task.findOne).to.be.calledWithMatch({
foo: 'bar',
alias: taskWithAlias.alias,
userId: user._id,
});
});
});
describe('sanitizeUserChallengeTask ', () => {
});
describe('sanitizeChecklist ', () => {
});
describe('sanitizeReminder ', () => {
});
describe('fromJSONV2 ', () => {
});
});
describe('Instance Methods', () => {
describe('scoreChallengeTask', () => {
});
describe('toJSONV2', () => {
});
});
}); });

View File

@@ -24,6 +24,7 @@ describe('shared.ops.updateTask', () => {
text: 'updated', text: 'updated',
id: '123', id: '123',
_id: '123', _id: '123',
shortName: 'short-name',
type: 'todo', type: 'todo',
tags: ['678'], tags: ['678'],
checklist: [{ checklist: [{
@@ -38,6 +39,7 @@ describe('shared.ops.updateTask', () => {
expect(res._id).to.not.equal('123'); expect(res._id).to.not.equal('123');
expect(res.type).to.equal('habit'); expect(res.type).to.equal('habit');
expect(res.text).to.equal('updated'); expect(res.text).to.equal('updated');
expect(res.shortName).to.eql('short-name');
expect(res.checklist).to.eql([{ expect(res.checklist).to.eql([{
completed: false, completed: false,
text: 'item', text: 'item',

View File

@@ -16,6 +16,22 @@ import logger from '../../libs/api-v3/logger';
let api = {}; let api = {};
async function _validateTaskAlias (tasks, res) {
let tasksWithAliases = tasks.filter(task => task.alias);
let aliases = tasksWithAliases.map(task => task.alias);
// Compares the short names in tasks against
// a Set, where values cannot repeat. If the
// lengths are different, some name was duplicated
if (aliases.length !== [...new Set(aliases)].length) {
throw new BadRequest(res.t('taskAliasAlreadyUsed'));
}
await Bluebird.map(tasksWithAliases, (task) => {
return task.validate();
});
}
// challenge must be passed only when a challenge task is being created // challenge must be passed only when a challenge task is being created
async function _createTasks (req, res, user, challenge) { async function _createTasks (req, res, user, challenge) {
let toSave = Array.isArray(req.body) ? req.body : [req.body]; let toSave = Array.isArray(req.body) ? req.body : [req.body];
@@ -42,7 +58,12 @@ async function _createTasks (req, res, user, challenge) {
(challenge || user).tasksOrder[`${taskType}s`].unshift(newTask._id); (challenge || user).tasksOrder[`${taskType}s`].unshift(newTask._id);
return newTask; return newTask;
}).map(task => task.save({ // If all tasks are valid (this is why it's not in the previous .map()), save everything, withough running validation again });
// tasks with aliases need to be validated asyncronously
await _validateTaskAlias(toSave, res);
toSave = toSave.map(task => task.save({ // If all tasks are valid (this is why it's not in the previous .map()), save everything, withough running validation again
validateBeforeSave: false, validateBeforeSave: false,
})); }));
@@ -233,7 +254,7 @@ api.getChallengeTasks = {
* @apiName GetTask * @apiName GetTask
* @apiGroup Task * @apiGroup Task
* *
* @apiParam {UUID} taskId The task _id * @apiParam {UUID|string} taskId The task _id or alias
* *
* @apiSuccess {object} data The task object * @apiSuccess {object} data The task object
*/ */
@@ -243,15 +264,8 @@ api.getTask = {
middlewares: [authWithHeaders()], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
let user = res.locals.user; let user = res.locals.user;
let taskId = req.params.taskId;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); let task = await Tasks.Task.findByIdOrAlias(taskId, user._id);
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({
_id: req.params.taskId,
}).exec();
if (!task) { if (!task) {
throw new NotFound(res.t('taskNotFound')); throw new NotFound(res.t('taskNotFound'));
@@ -274,7 +288,7 @@ api.getTask = {
* @apiName UpdateTask * @apiName UpdateTask
* @apiGroup Task * @apiGroup Task
* *
* @apiParam {UUID} taskId The task _id * @apiParam {UUID|string} taskId The task _id or alias
* *
* @apiSuccess {object} data The updated task * @apiSuccess {object} data The updated task
*/ */
@@ -286,14 +300,13 @@ api.updateTask = {
let user = res.locals.user; let user = res.locals.user;
let challenge; let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('taskId', res.t('taskIdRequired')).notEmpty();
let validationErrors = req.validationErrors(); let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors; if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({ let taskId = req.params.taskId;
_id: req.params.taskId, let task = await Tasks.Task.findByIdOrAlias(taskId, user._id);
}).exec();
if (!task) { if (!task) {
throw new NotFound(res.t('taskNotFound')); throw new NotFound(res.t('taskNotFound'));
@@ -308,7 +321,6 @@ api.updateTask = {
// we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances? // we have to convert task to an object because otherwise things don't get merged correctly. Bad for performances?
let [updatedTaskObj] = common.ops.updateTask(task.toObject(), req); let [updatedTaskObj] = common.ops.updateTask(task.toObject(), req);
// Sanitize differently user tasks linked to a challenge // Sanitize differently user tasks linked to a challenge
let sanitizedObj; let sanitizedObj;
@@ -362,7 +374,7 @@ function _generateWebhookTaskData (task, direction, delta, stats, user) {
* @apiName ScoreTask * @apiName ScoreTask
* @apiGroup Task * @apiGroup Task
* *
* @apiParam {UUID} taskId The task _id * @apiParam {UUID|string} taskId The task _id or alias
* @apiParam {string="up","down"} direction The direction for scoring the task * @apiParam {string="up","down"} direction The direction for scoring the task
* *
* @apiSuccess {object} data._tmp If an item was dropped it'll be returned in te _tmp object * @apiSuccess {object} data._tmp If an item was dropped it'll be returned in te _tmp object
@@ -374,19 +386,16 @@ api.scoreTask = {
url: '/tasks/:taskId/score/:direction', url: '/tasks/:taskId/score/:direction',
middlewares: [authWithHeaders()], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']); req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']);
let validationErrors = req.validationErrors(); let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors; if (validationErrors) throw validationErrors;
let user = res.locals.user; let user = res.locals.user;
let direction = req.params.direction; let {taskId} = req.params;
let task = await Tasks.Task.findOne({ let task = await Tasks.Task.findByIdOrAlias(taskId, user._id, {userId: user._id});
_id: req.params.taskId, let direction = req.params.direction;
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound')); if (!task) throw new NotFound(res.t('taskNotFound'));
@@ -448,7 +457,7 @@ api.scoreTask = {
* @apiName MoveTask * @apiName MoveTask
* @apiGroup Task * @apiGroup Task
* *
* @apiParam {UUID} taskId The task _id * @apiParam {UUID|string} taskId The task _id or alias
* @apiParam {Number} position Query parameter - Where to move the task (-1 means push to bottom). First position is 0 * @apiParam {Number} position Query parameter - Where to move the task (-1 means push to bottom). First position is 0
* *
* @apiSuccess {array} data The new tasks order (user.tasksOrder.{task.type}s) * @apiSuccess {array} data The new tasks order (user.tasksOrder.{task.type}s)
@@ -458,19 +467,17 @@ api.moveTask = {
url: '/tasks/:taskId/move/to/:position', url: '/tasks/:taskId/move/to/:position',
middlewares: [authWithHeaders()], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('taskId', res.t('taskIdRequired')).notEmpty();
req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric(); req.checkParams('position', res.t('positionRequired')).notEmpty().isNumeric();
let validationErrors = req.validationErrors(); let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors; if (validationErrors) throw validationErrors;
let user = res.locals.user; let user = res.locals.user;
let taskId = req.params.taskId;
let to = Number(req.params.position); let to = Number(req.params.position);
let task = await Tasks.Task.findOne({ let task = await Tasks.Task.findByIdOrAlias(taskId, user._id, { userId: user._id });
_id: req.params.taskId,
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound')); if (!task) throw new NotFound(res.t('taskNotFound'));
if (task.type === 'todo' && task.completed) throw new BadRequest(res.t('cantMoveCompletedTodo')); if (task.type === 'todo' && task.completed) throw new BadRequest(res.t('cantMoveCompletedTodo'));
@@ -503,7 +510,7 @@ api.moveTask = {
* @apiName AddChecklistItem * @apiName AddChecklistItem
* @apiGroup Task * @apiGroup Task
* *
* @apiParam {UUID} taskId The task _id * @apiParam {UUID|string} taskId The task _id or alias
* *
* @apiSuccess {object} data The updated task * @apiSuccess {object} data The updated task
*/ */
@@ -515,14 +522,13 @@ api.addChecklistItem = {
let user = res.locals.user; let user = res.locals.user;
let challenge; let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('taskId', res.t('taskIdRequired')).notEmpty();
let validationErrors = req.validationErrors(); let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors; if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({ let taskId = req.params.taskId;
_id: req.params.taskId, let task = await Tasks.Task.findByIdOrAlias(taskId, user._id);
}).exec();
if (!task) { if (!task) {
throw new NotFound(res.t('taskNotFound')); throw new NotFound(res.t('taskNotFound'));
@@ -552,7 +558,7 @@ api.addChecklistItem = {
* @apiName ScoreChecklistItem * @apiName ScoreChecklistItem
* @apiGroup Task * @apiGroup Task
* *
* @apiParam {UUID} taskId The task _id * @apiParam {UUID|string} taskId The task _id or alias
* @apiParam {UUID} itemId The checklist item _id * @apiParam {UUID} itemId The checklist item _id
* *
* @apiSuccess {object} data The updated task * @apiSuccess {object} data The updated task
@@ -564,16 +570,14 @@ api.scoreCheckListItem = {
async handler (req, res) { async handler (req, res) {
let user = res.locals.user; let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('taskId', res.t('taskIdRequired')).notEmpty();
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors(); let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors; if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({ let taskId = req.params.taskId;
_id: req.params.taskId, let task = await Tasks.Task.findByIdOrAlias(taskId, user._id, { userId: user._id });
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound')); if (!task) throw new NotFound(res.t('taskNotFound'));
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo')); if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
@@ -594,7 +598,7 @@ api.scoreCheckListItem = {
* @apiName UpdateChecklistItem * @apiName UpdateChecklistItem
* @apiGroup Task * @apiGroup Task
* *
* @apiParam {UUID} taskId The task _id * @apiParam {UUID|string} taskId The task _id or alias
* @apiParam {UUID} itemId The checklist item _id * @apiParam {UUID} itemId The checklist item _id
* *
* @apiSuccess {object} data The updated task * @apiSuccess {object} data The updated task
@@ -607,15 +611,14 @@ api.updateChecklistItem = {
let user = res.locals.user; let user = res.locals.user;
let challenge; let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('taskId', res.t('taskIdRequired')).notEmpty();
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors(); let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors; if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({ let taskId = req.params.taskId;
_id: req.params.taskId, let task = await Tasks.Task.findByIdOrAlias(taskId, user._id);
}).exec();
if (!task) { if (!task) {
throw new NotFound(res.t('taskNotFound')); throw new NotFound(res.t('taskNotFound'));
@@ -647,7 +650,7 @@ api.updateChecklistItem = {
* @apiName RemoveChecklistItem * @apiName RemoveChecklistItem
* @apiGroup Task * @apiGroup Task
* *
* @apiParam {UUID} taskId The task _id * @apiParam {UUID|string} taskId The task _id or alias
* @apiParam {UUID} itemId The checklist item _id * @apiParam {UUID} itemId The checklist item _id
* *
* @apiSuccess {object} data The updated task * @apiSuccess {object} data The updated task
@@ -660,15 +663,14 @@ api.removeChecklistItem = {
let user = res.locals.user; let user = res.locals.user;
let challenge; let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('taskId', res.t('taskIdRequired')).notEmpty();
req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID(); req.checkParams('itemId', res.t('itemIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors(); let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors; if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({ let taskId = req.params.taskId;
_id: req.params.taskId, let task = await Tasks.Task.findByIdOrAlias(taskId, user._id);
}).exec();
if (!task) { if (!task) {
throw new NotFound(res.t('taskNotFound')); throw new NotFound(res.t('taskNotFound'));
@@ -698,7 +700,7 @@ api.removeChecklistItem = {
* @apiName AddTagToTask * @apiName AddTagToTask
* @apiGroup Task * @apiGroup Task
* *
* @apiParam {UUID} taskId The task _id * @apiParam {UUID|string} taskId The task _id or alias
* @apiParam {UUID} tagId The tag id * @apiParam {UUID} tagId The tag id
* *
* @apiSuccess {object} data The updated task * @apiSuccess {object} data The updated task
@@ -710,17 +712,15 @@ api.addTagToTask = {
async handler (req, res) { async handler (req, res) {
let user = res.locals.user; let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('taskId', res.t('taskIdRequired')).notEmpty();
let userTags = user.tags.map(tag => tag.id); let userTags = user.tags.map(tag => tag.id);
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID().isIn(userTags); req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID().isIn(userTags);
let validationErrors = req.validationErrors(); let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors; if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({ let taskId = req.params.taskId;
_id: req.params.taskId, let task = await Tasks.Task.findByIdOrAlias(taskId, user._id, { userId: user._id });
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound')); if (!task) throw new NotFound(res.t('taskNotFound'));
let tagId = req.params.tagId; let tagId = req.params.tagId;
@@ -741,7 +741,7 @@ api.addTagToTask = {
* @apiName RemoveTagFromTask * @apiName RemoveTagFromTask
* @apiGroup Task * @apiGroup Task
* *
* @apiParam {UUID} taskId The task _id * @apiParam {UUID|string} taskId The task _id or alias
* @apiParam {UUID} tagId The tag id * @apiParam {UUID} tagId The tag id
* *
* @apiSuccess {object} data The updated task * @apiSuccess {object} data The updated task
@@ -753,16 +753,14 @@ api.removeTagFromTask = {
async handler (req, res) { async handler (req, res) {
let user = res.locals.user; let user = res.locals.user;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID(); req.checkParams('taskId', res.t('taskIdRequired')).notEmpty();
req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID(); req.checkParams('tagId', res.t('tagIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors(); let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors; if (validationErrors) throw validationErrors;
let task = await Tasks.Task.findOne({ let taskId = req.params.taskId;
_id: req.params.taskId, let task = await Tasks.Task.findByIdOrAlias(taskId, user._id, { userId: user._id });
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound')); if (!task) throw new NotFound(res.t('taskNotFound'));
@@ -842,7 +840,7 @@ api.unlinkAllTasks = {
* @apiName UnlinkOneTask * @apiName UnlinkOneTask
* @apiGroup Task * @apiGroup Task
* *
* @apiParam {UUID} taskId The task _id * @apiParam {UUID|string} taskId The task _id or alias
* @apiParam {string} keep Query parameter - keep or remove * @apiParam {string} keep Query parameter - keep or remove
* *
* @apiSuccess {object} data An empty object * @apiSuccess {object} data An empty object
@@ -862,10 +860,7 @@ api.unlinkOneTask = {
let keep = req.query.keep; let keep = req.query.keep;
let taskId = req.params.taskId; let taskId = req.params.taskId;
let task = await Tasks.Task.findOne({ let task = await Tasks.Task.findByIdOrAlias(taskId, user._id, { userId: user._id });
_id: taskId,
userId: user._id,
}).exec();
if (!task) throw new NotFound(res.t('taskNotFound')); if (!task) throw new NotFound(res.t('taskNotFound'));
if (!task.challenge.id) throw new BadRequest(res.t('cantOnlyUnlinkChalTask')); if (!task.challenge.id) throw new BadRequest(res.t('cantOnlyUnlinkChalTask'));
@@ -924,7 +919,7 @@ api.clearCompletedTodos = {
* @apiName DeleteTask * @apiName DeleteTask
* @apiGroup Task * @apiGroup Task
* *
* @apiParam {UUID} taskId The task _id * @apiParam {UUID|string} taskId The task _id or alias
* *
* @apiSuccess {object} data An empty object * @apiSuccess {object} data An empty object
*/ */
@@ -936,13 +931,8 @@ api.deleteTask = {
let user = res.locals.user; let user = res.locals.user;
let challenge; let challenge;
req.checkParams('taskId', res.t('taskIdRequired')).notEmpty().isUUID();
let validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let taskId = req.params.taskId; let taskId = req.params.taskId;
let task = await Tasks.Task.findById(taskId).exec(); let task = await Tasks.Task.findByIdOrAlias(taskId, user._id);
if (!task) { if (!task) {
throw new NotFound(res.t('taskNotFound')); throw new NotFound(res.t('taskNotFound'));

View File

@@ -3,6 +3,7 @@ import shared from '../../../common';
import validator from 'validator'; import validator from 'validator';
import moment from 'moment'; import moment from 'moment';
import baseModel from '../libs/api-v3/baseModel'; import baseModel from '../libs/api-v3/baseModel';
import { InternalServerError } from '../libs/api-v3/errors';
import _ from 'lodash'; import _ from 'lodash';
import { preenHistory } from '../libs/api-v3/preening'; import { preenHistory } from '../libs/api-v3/preening';
@@ -25,6 +26,21 @@ export let TaskSchema = new Schema({
type: {type: String, enum: tasksTypes, required: true, default: tasksTypes[0]}, type: {type: String, enum: tasksTypes, required: true, default: tasksTypes[0]},
text: {type: String, required: true}, text: {type: String, required: true},
notes: {type: String, default: ''}, notes: {type: String, default: ''},
alias: {
type: String,
match: [/^[a-zA-Z0-9-_]+$/, 'Task short names can only contain alphanumeric characters, underscores and dashes.'],
validate: [{
validator () {
return Boolean(this.userId);
},
msg: 'Task short names can only be applied to tasks in a user\'s own task list.',
}, {
validator (val) {
return !validator.isUUID(val);
},
msg: 'Task short names cannot be uuids.',
}],
},
tags: [{ tags: [{
type: String, type: String,
validate: [validator.isUUID, 'Invalid uuid.'], validate: [validator.isUUID, 'Invalid uuid.'],
@@ -73,6 +89,25 @@ TaskSchema.plugin(baseModel, {
timestamps: true, timestamps: true,
}); });
TaskSchema.statics.findByIdOrAlias = async function findByIdOrAlias (identifier, userId, additionalQueries = {}) {
// not using i18n strings because these errors are meant for devs who forgot to pass some parameters
if (!identifier) throw new InternalServerError('Task identifier is a required argument');
if (!userId) throw new InternalServerError('User identifier is a required argument');
let query = _.cloneDeep(additionalQueries);
if (validator.isUUID(identifier)) {
query._id = identifier;
} else {
query.userId = userId;
query.alias = identifier;
}
let task = await this.findOne(query).exec();
return task;
};
// Sanitize user tasks linked to a challenge // Sanitize user tasks linked to a challenge
// See http://habitica.wikia.com/wiki/Challenges#Challenge_Participant.27s_Permissions for more info // See http://habitica.wikia.com/wiki/Challenges#Challenge_Participant.27s_Permissions for more info
TaskSchema.statics.sanitizeUserChallengeTask = function sanitizeUserChallengeTask (taskObj) { TaskSchema.statics.sanitizeUserChallengeTask = function sanitizeUserChallengeTask (taskObj) {
@@ -167,6 +202,20 @@ TaskSchema.statics.fromJSONV2 = function fromJSONV2 (taskObj) {
export let Task = mongoose.model('Task', TaskSchema); export let Task = mongoose.model('Task', TaskSchema);
Task.schema.path('alias').validate(function valiateAliasNotTaken (alias, respond) {
Task.findOne({
_id: { $ne: this._id },
userId: this.userId,
alias,
}).exec().then((task) => {
let aliasAvailable = !task;
respond(aliasAvailable);
}).catch(() => {
respond(false);
});
}, 'Task alias already used on another task.');
// habits and dailies shared fields // habits and dailies shared fields
let habitDailySchema = () => { let habitDailySchema = () => {
return {history: Array}; // [{date:Date, value:Number}], // this causes major performance problems return {history: Array}; // [{date:Date, value:Number}], // this causes major performance problems

View File

@@ -4,6 +4,11 @@ div(ng-if='::task.type!="reward"')
ng-click='task._advanced = !task._advanced', tooltip=env.t('expandCollapse')) ng-click='task._advanced = !task._advanced', tooltip=env.t('expandCollapse'))
=env.t('advancedOptions') =env.t('advancedOptions')
fieldset.option-group.advanced-option(ng-if="task.userId" ng-show="task._advanced")
legend.option-title
a.hint(href='http://habitica.wikia.com/wiki/Task_Alias', target='_blank', popover-trigger='mouseenter', popover="{{::env.t('taskAliasPopover')}} {{::task.alias ? '\n\n\' + env.t('taskAliasPopoverWarning') : ''}}")=env.t('taskAlias')
input.form-control(ng-model='task.alias' type='text' placeholder=env.t('taskAliasPlaceholder'))
div(ng-show='task._advanced') div(ng-show='task._advanced')
div(ng-if='::task.type == "daily"') div(ng-if='::task.type == "daily"')
.form-group .form-group