PageRenderTime 92ms CodeModel.GetById 27ms RepoModel.GetById 3ms app.codeStats 0ms

/javascript/profile-package/badges.js

https://gitlab.com/gregtyka/KhanLatest
JavaScript | 689 lines | 431 code | 85 blank | 173 comment | 44 complexity | d166225e0141d81acf367663ffe54c2f MD5 | raw file
  1. /**
  2. * Code to handle badge-related UI components.
  3. */
  4. // TODO: stop clobering the stuff in pageutil.js
  5. var Badges = window.Badges || {};
  6. /**
  7. * @enum {number}
  8. */
  9. Badges.ContextType = {
  10. NONE: 0,
  11. EXERCISE: 1,
  12. PLAYLIST: 2
  13. };
  14. /**
  15. * @enum {number}
  16. */
  17. Badges.Category = {
  18. BRONZE: 0, // Meteorite, "Common"
  19. SILVER: 1, // Moon, "Uncommon"
  20. GOLD: 2, // Earth, "Rare"
  21. PLATINUM: 3, // Sun, "Epic"
  22. DIAMOND: 4, // Black Hole, "Legendary"
  23. MASTER: 5 // Topic/Academic Achievement
  24. };
  25. /**
  26. * A single badge that a user can earn.
  27. * Parallel to the JSON serialized formats of badges.Badge
  28. */
  29. Badges.Badge = Backbone.Model.extend({
  30. defaults: {
  31. "badgeCategory": Badges.Category.BRONZE,
  32. "name": "__empty__",
  33. "description": "",
  34. "icons": {},
  35. "isOwned": false,
  36. "points": 0,
  37. "safeExtendedDescription": ""
  38. },
  39. isEmpty: function() {
  40. // Specially reserved name for empty badge slots.
  41. // Used in display case - must be synced with what the server
  42. // understands in util_badges.py
  43. return this.get("name") === "__empty__";
  44. },
  45. toJSON: function() {
  46. var json = Badges.Badge.__super__.toJSON.call(this);
  47. json["isEmpty"] = this.isEmpty();
  48. return json;
  49. }
  50. });
  51. /**
  52. * A re-usable instance of an empty badge.
  53. */
  54. Badges.Badge.EMPTY_BADGE = new Badges.Badge({});
  55. /**
  56. * Badge information about a badge, or a set of badges that a user has earned
  57. * grouped by their badge type.
  58. * Parallel to the JSON serialized formats of badges.GroupedUserBadge
  59. */
  60. Badges.UserBadge = Backbone.Model.extend({
  61. defaults: {
  62. "badge": null,
  63. "count": 1,
  64. "lastEarnedDate": "2011-11-22T00:00:00Z",
  65. "targetContextNames": [],
  66. "isOwned": true
  67. },
  68. initialize: function(attributes, options) {
  69. if (!this.get("badge")) {
  70. throw "A UserBadge object needs a reference badge object";
  71. }
  72. // Wrap the underlying badge info in a Model object and forward
  73. // change events.
  74. var badgeModel = new Badges.Badge(this.get("badge"));
  75. this.set({ "badge": badgeModel }, { "silent": true });
  76. badgeModel.bind(
  77. "change",
  78. function(ev) { this.trigger("change:badge"); },
  79. this);
  80. }
  81. });
  82. /**
  83. * Add extra context to userBadge for use when rendering
  84. * user-badge.handlebars
  85. *
  86. * @param {Object} userBadge, a JSON representation of a GroupedUserBadge
  87. * @return {Object} The same userBadge object passed in after having been
  88. * modified
  89. */
  90. Badges.addUserBadgeContext = function(userBadge) {
  91. var targetContextNames = userBadge["targetContextNames"];
  92. var numHidden = targetContextNames.length - 1;
  93. userBadge["visibleContextName"] = targetContextNames[0] || "";
  94. userBadge["listContextNamesHidden"] = $.map(
  95. targetContextNames.slice(1),
  96. function(name, nameIndex) {
  97. return {
  98. name: name,
  99. isLast: (nameIndex === numHidden - 1)
  100. };
  101. });
  102. userBadge["hasMultiple"] = (userBadge["count"] > 1);
  103. return userBadge;
  104. };
  105. /**
  106. * A list of badges that can be listened to.
  107. * This list can be edited by adding or removing from the collection,
  108. * and saved up to a server.
  109. */
  110. Badges.BadgeList = Backbone.Collection.extend({
  111. model: Badges.Badge,
  112. saveUrl: null,
  113. /**
  114. * Whether or not this badge list has been modified since the last
  115. * save to the server.
  116. */
  117. dirty_: false,
  118. setSaveUrl: function(url) {
  119. this.saveUrl = url;
  120. },
  121. toJSON: function() {
  122. return this.map(function(badge) {
  123. return badge.get("name");
  124. });
  125. },
  126. add: function(models, options) {
  127. Badges.BadgeList.__super__.add.apply(this, arguments);
  128. this.dirty_ = true;
  129. },
  130. remove: function(models, options) {
  131. Badges.BadgeList.__super__.remove.apply(this, arguments);
  132. this.dirty_ = true;
  133. },
  134. /**
  135. * Saves the collection to the server via Backbone.sync.
  136. * This does *not* save any individual edits to Badges within this list;
  137. * it simply posts the information about what belongs in the set.
  138. * @param {Object} options Options similar to what Backbone.sync accepts.
  139. */
  140. save: function(options) {
  141. if (!this.dirty_) {
  142. return;
  143. }
  144. options = options || {};
  145. options["url"] = this.saveUrl;
  146. options["contentType"] = "application/json";
  147. options["data"] = JSON.stringify(this.map(function(badge) {
  148. return badge.get("name");
  149. }));
  150. Backbone.sync.call(this, "update", this, options);
  151. this.dirty_ = false;
  152. },
  153. // TODO: figure out how to do this in a more systematic way!
  154. // Override base Backbone.parse since badge modifications can result in
  155. // api_action_results to be sent back.
  156. parse: function(resp, xhr) {
  157. if ("apiActionResults" in resp && "payload" in resp) {
  158. resp = resp["payload"];
  159. }
  160. Backbone.Model.prototype.parse.call(this, resp, xhr);
  161. }
  162. });
  163. /**
  164. * A list of user badges that can be listened to.
  165. */
  166. Badges.UserBadgeList = Backbone.Collection.extend({
  167. model: Badges.UserBadge
  168. });
  169. /**
  170. * A UI component that displays a list of badges to show off.
  171. * Typically used in a public profile page, but can be re-used
  172. * in the context of a hovercard, or any other context.
  173. *
  174. * Expects a Badges.BadgeList model to back it.
  175. */
  176. Badges.DisplayCase = Backbone.View.extend({
  177. className: "badge-display-case",
  178. /**
  179. * Whether or not this is currently in edit mode.
  180. */
  181. editing: false,
  182. /**
  183. * The full user badge list available to pick from when in edit mode.
  184. * @type {Badges.UserBadgeList}
  185. */
  186. fullBadgeList: null,
  187. /**
  188. * The number of slots available in the display case.
  189. */
  190. maxVisible: 5,
  191. /**
  192. * The slot number being edited. Any selection from the badge picker
  193. * will replace the badge in this slot number.
  194. * -1 if not currently editing.
  195. */
  196. selectedIndex: -1,
  197. mainCaseEl: null,
  198. badgePickerEl: null,
  199. editControlEl: null,
  200. /**
  201. * Ephemeral element used in animating a selection.
  202. */
  203. animatingBadgeEl: null,
  204. initialize: function() {
  205. this.model.bind("add", this.render, this);
  206. this.model.bind("remove", this.render, this);
  207. this.model.bind("change", this.render, this);
  208. this.template = Templates.get("profile.badge-display-case");
  209. Handlebars.registerHelper("toBadgeDescriptionWithBreaks", function(description) {
  210. var lines = [];
  211. var line = "";
  212. _.each(description.split(" "), function(word) {
  213. if (line.length > 0) {
  214. // Split description into up to two lines
  215. if (line.length + word.length > 12 && lines.length == 0) {
  216. // Insert newline, break it up
  217. lines[lines.length] = line;
  218. line = "";
  219. }
  220. else {
  221. line += " ";
  222. }
  223. }
  224. line += word;
  225. });
  226. if (line) {
  227. lines[lines.length] = line;
  228. }
  229. // Guarantee 2 lines for consistent height
  230. while (lines.length < 2) {
  231. lines[lines.length] = "&nbsp;";
  232. }
  233. return lines.join("\n");
  234. });
  235. },
  236. events: {
  237. "click .main-case .achievement-badge .delete-icon": "onDeleteBadgeClicked_",
  238. "click .main-case .achievement-badge": "onBadgeClicked_",
  239. "click .badge-picker .achievement-badge": "onBadgeInPickerClicked_",
  240. "click .display-case-cover": "onCoverClicked_"
  241. },
  242. /**
  243. * @return {boolean} Whether or not this display case can go into "edit" mode
  244. * to allow a user to select which badges go inside.
  245. */
  246. isEditable: function() {
  247. return !!this.fullBadgeList;
  248. },
  249. /**
  250. * Sets the full badge list for the display case so it can go into edit
  251. * mode and pick badges from this badge list.
  252. * @param {Badges.UserBadgeList} The full list of badges that can be added
  253. * to this display case.
  254. * @return {Badges.DisplayCase} This same instance so calls can be chained.
  255. */
  256. setFullBadgeList: function(fullBadgeList) {
  257. // TODO: do we want to listen to events on the full badge list?
  258. this.fullBadgeList = fullBadgeList;
  259. $(this.editControlEl).toggleClass("editable", this.isEditable());
  260. },
  261. /**
  262. * Enters "edit mode" where badges can be added/removed, if possible.
  263. * @param {number=} index Optional index of the slot in the display-case
  264. * to be edited. Defaults to the first available slot, or if none
  265. * are available, the last used slot.
  266. * @return {Badges.DisplayCase} This same instance so calls can be chained.
  267. */
  268. edit: function(index) {
  269. if (!this.isEditable() || this.editing) {
  270. return this;
  271. }
  272. this.setEditing_(true);
  273. this.updateEditSelection_(index);
  274. this.showBadgePicker_();
  275. this.editControlEl.slideUp(350);
  276. this.mainCaseEl.addClass("enable-scrolling");
  277. $(document).bind("mousedown", this.getBoundStopEditFn_());
  278. return this;
  279. },
  280. /**
  281. * Updates the editor so that the badge at the specified index is
  282. * being edited. If no index is specified, the last possible spot
  283. * is selected by default.
  284. * @param {number=} index Optional index of the slot in the display-case
  285. * to be edited. -1 to indicate that none should be selected (i.e.
  286. * we're exiting edit mode.
  287. */
  288. updateEditSelection_: function(index) {
  289. // By default, select the first empty slot, or the last non-empty
  290. // slot if completely full.
  291. if (index === undefined) {
  292. for (var i = 0, len = this.model.length; i < len; i++) {
  293. if (this.model.at(i).isEmpty()) {
  294. index = i;
  295. break;
  296. }
  297. }
  298. }
  299. index = (index === undefined) ? this.model.length : index;
  300. this.selectedIndex = Math.min(index, this.maxVisible - 1);
  301. this.updateSelectionHighlight();
  302. },
  303. /**
  304. * Shows the badge picker for edit mode, if not already visible.
  305. * This view must have already have been rendered once.
  306. */
  307. showBadgePicker_: function() {
  308. this.renderBadgePicker();
  309. var jel = $(this.el);
  310. var jelPicker = $(this.badgePickerEl);
  311. jelPicker.slideDown("fast", function() { jelPicker.show(); })
  312. .css("margin-left", "300px")
  313. .animate({ "margin-left": "0" }, {
  314. duration: "fast",
  315. step: $.easing.easeInOutCubic,
  316. complete: function() {
  317. jel.addClass("editing");
  318. }
  319. });
  320. return this;
  321. },
  322. /**
  323. * Handles a click to a badge in the main display case.
  324. */
  325. onBadgeClicked_: function(e) {
  326. if (!this.editing) {
  327. // Noop when not editing.
  328. return;
  329. }
  330. var index = $(this.mainCaseEl)
  331. .find(".achievement-badge")
  332. .index(e.currentTarget);
  333. this.updateEditSelection_(index);
  334. e.stopPropagation();
  335. },
  336. /**
  337. * Handles a click to a delete button for a badge in the main display case.
  338. */
  339. onDeleteBadgeClicked_: function(e) {
  340. // Prevent the badge click from being processed, since
  341. // the X is a child of the badge.
  342. e.stopPropagation();
  343. if (!this.editing) {
  344. // Noop when not editing.
  345. return;
  346. }
  347. var badgeNode = e.currentTarget;
  348. while (badgeNode && !$(badgeNode).hasClass("achievement-badge")) {
  349. badgeNode = badgeNode.parentNode;
  350. }
  351. var index = $(this.mainCaseEl)
  352. .find(".achievement-badge")
  353. .index(badgeNode);
  354. // Store position before it's removed.
  355. var fromOffset = $(badgeNode).offset();
  356. var isLast = index == (this.model.length - 1);
  357. var removedBadge = this.model.at(index);
  358. this.model.remove(removedBadge);
  359. if (!isLast) {
  360. // Insert an empty badge, since we don't want things shifting
  361. this.model.add(Badges.Badge.EMPTY_BADGE.clone(), { at: index });
  362. }
  363. this.updateEditSelection_(index);
  364. // Animate-out the deleted badge
  365. this.ensureAnimatingBadgeEl();
  366. var badgeTemplate = Templates.get("profile.badge-compact");
  367. this.animatingBadgeEl.html(badgeTemplate(removedBadge.toJSON()));
  368. this.animatingBadgeEl.css({
  369. left: fromOffset.left,
  370. top: fromOffset.top,
  371. opacity: 1.0
  372. });
  373. this.animatingBadgeEl.show();
  374. this.animatingBadgeEl.animate({
  375. left: fromOffset.left + 5,
  376. top: fromOffset.top + 10,
  377. opacity: 0
  378. }, {
  379. duration: 250,
  380. step: $.easing.easeInOutCubic,
  381. complete: _.bind(function() {
  382. this.animatingBadgeEl.hide();
  383. this.animatingBadgeEl.css({ opacity: 1.0 });
  384. }, this)
  385. });
  386. },
  387. /**
  388. * Handles a click to a badge in the badge picker in edit mode.
  389. */
  390. onBadgeInPickerClicked_: function(e) {
  391. e.stopPropagation();
  392. if ($(e.currentTarget).hasClass("used")) {
  393. // Ignore badges already in the main case.
  394. return;
  395. }
  396. var name = e.currentTarget.id;
  397. var matchedBadge = _.find(
  398. this.fullBadgeList.models,
  399. function(userBadge) {
  400. return userBadge.get("badge").get("name") == name;
  401. });
  402. if (!matchedBadge) {
  403. // Shouldn't happen!
  404. return;
  405. }
  406. var badgeToAdd = matchedBadge.get("badge").clone();
  407. this.beginSelectionAnimation_(
  408. badgeToAdd, $(e.currentTarget), this.selectedIndex);
  409. },
  410. ensureAnimatingBadgeEl: function() {
  411. if (!this.animatingBadgeEl) {
  412. this.animatingBadgeEl = $("<div id='animating-badge'></div>")
  413. .appendTo("body");
  414. }
  415. },
  416. /**
  417. * Begin an animation to select a badge from the picker so that
  418. * it may be added to the main display case.
  419. *
  420. * @param {Badges.Badge} badgeSelected The badge to add
  421. * @param {jQuery} jelBadgeSelected The jQuery element of the badge
  422. * element that was selected in the picker.
  423. * @param {number} index The slot in the display case to add to.
  424. */
  425. beginSelectionAnimation_: function(
  426. badgeSelected, jelBadgeSelected, index) {
  427. this.ensureAnimatingBadgeEl();
  428. var jelTargetSlot = $(this.mainCaseEl)
  429. .find(".achievement-badge").eq(index);
  430. var badgeTemplate = Templates.get("profile.badge-compact");
  431. this.animatingBadgeEl.html(badgeTemplate(badgeSelected.toJSON()));
  432. var fromOffset = jelBadgeSelected.offset();
  433. this.animatingBadgeEl.css({
  434. left: fromOffset.left,
  435. top: fromOffset.top
  436. });
  437. this.animatingBadgeEl.show();
  438. var toOffset = jelTargetSlot.offset();
  439. this.animatingBadgeEl.animate({
  440. left: toOffset.left,
  441. top: toOffset.top
  442. }, {
  443. duration: 250,
  444. step: $.easing.easeInOutCubic,
  445. complete: _.bind(function() {
  446. this.finishSelection_(badgeSelected, index);
  447. }, this)
  448. });
  449. },
  450. finishSelection_: function(badgeToAdd, index) {
  451. if (!this.animatingBadgeEl) {
  452. return;
  453. }
  454. this.animatingBadgeEl.hide();
  455. this.animatingBadgeEl.html("");
  456. // Do the actual selection!
  457. // Backbone.Collection doesn't have a .replace method - do it ourselves
  458. var existing = this.model.at(index);
  459. if (existing) {
  460. this.model.remove(existing);
  461. }
  462. for (var i = this.model.length; i < index; i++) {
  463. // Ensure we pad the list with empty badges if the user is
  464. // inserting after some holes.
  465. this.model.add(Badges.Badge.EMPTY_BADGE.clone());
  466. }
  467. this.model.add(badgeToAdd, { at: index });
  468. // Pick the next empty slot.
  469. this.updateEditSelection_();
  470. },
  471. /**
  472. * Exits edit mode.
  473. */
  474. stopEdit: function() {
  475. if (this.editing) {
  476. this.setEditing_(false);
  477. this.updateEditSelection_(-1);
  478. var jelRootEl = $(this.el);
  479. var jelPicker = $(this.badgePickerEl);
  480. jelPicker.slideUp("fast", function() {
  481. jelRootEl.removeClass("editing");
  482. });
  483. jelPicker.undelegate();
  484. this.editControlEl.slideDown(250);
  485. this.mainCaseEl.removeClass("enable-scrolling");
  486. $(document).unbind("click", this.getBoundStopEditFn_());
  487. // TODO: avoid saving if not dirty.
  488. this.save();
  489. }
  490. return this;
  491. },
  492. getBoundStopEditFn_: function() {
  493. if (this.boundStopEditFn_) {
  494. return this.boundStopEditFn_;
  495. }
  496. var self = this;
  497. return this.boundStopEditFn_ = function(e) {
  498. for (var node = e.target; node; node = node.parentNode) {
  499. if (node === self.el) {
  500. // Click inside the display-case somewhere - ignore.
  501. return;
  502. }
  503. }
  504. self.stopEdit();
  505. };
  506. },
  507. save: function() {
  508. this.model.save();
  509. },
  510. setEditing_: function(editing) {
  511. this.editing = editing;
  512. },
  513. /**
  514. * Builds a context object to render a single badge.
  515. */
  516. getUserBadgeJsonContext_: function(badge) {
  517. var json = badge.get("badge").toJSON();
  518. json["count"] = badge.get("count");
  519. return json;
  520. },
  521. /**
  522. * Renders the contents of the main case.
  523. */
  524. renderMainCaseContents_: function() {
  525. var i,
  526. template = Templates.get("profile.badge-compact"),
  527. html = [],
  528. numRendered = Math.min(this.maxVisible, this.model.length);
  529. // While creating the JSON context, also update the badge overlays in
  530. // the display case cover.
  531. var overlays = this.editControlEl.find(".achievement-badge");
  532. for (i = 0; i < numRendered; i++) {
  533. var badge = this.model.at(i);
  534. html.push(template(badge.toJSON()));
  535. overlays[i].setAttribute(
  536. "title",
  537. badge.get("safeExtendedDescription"));
  538. }
  539. for (; i < this.maxVisible; i++) {
  540. html.push(template(Badges.Badge.EMPTY_BADGE.toJSON()));
  541. overlays[i].setAttribute("title", "");
  542. }
  543. this.mainCaseEl.html(html.join(""));
  544. },
  545. /**
  546. * Updates the appropriate badge being highlighted for edit mode.
  547. * See {@link #selectedIndex} for more details.
  548. */
  549. updateSelectionHighlight: function() {
  550. var badgeSlots = $(".achievement-badge", this.mainCaseEl);
  551. badgeSlots.removeClass("selected");
  552. if (this.selectedIndex > -1) {
  553. $(badgeSlots[this.selectedIndex]).addClass("selected");
  554. }
  555. },
  556. onCoverClicked_: function(e) {
  557. if (this.isEditable()) {
  558. this.edit();
  559. }
  560. e.stopPropagation();
  561. },
  562. /**
  563. * Renders the contents of the badge picker.
  564. * Idempotent - simply blows away and repopulates the contents if called
  565. * multiple times.
  566. */
  567. renderBadgePicker: function() {
  568. if (this.fullBadgeList.isEmpty()) {
  569. $(this.badgePickerEl).html(
  570. Templates.get("profile.empty-badge-picker")());
  571. return;
  572. }
  573. var html = [],
  574. badgeTemplate = Templates.get("profile.badge-compact");
  575. this.fullBadgeList.each(function(userBadge) {
  576. var alreadyInCase = this.model.find(function(b) {
  577. return b.get("name") === userBadge.get("badge").get("name");
  578. });
  579. // Mark badges that are already used in the display case
  580. var jsonContext = this.getUserBadgeJsonContext_(userBadge);
  581. if (alreadyInCase) {
  582. jsonContext["used"] = true;
  583. }
  584. html.push(badgeTemplate(jsonContext));
  585. }, this);
  586. $(this.badgePickerEl).html(html.join(""));
  587. },
  588. render: function() {
  589. if (!this.mainCaseEl) {
  590. // First render - build the chrome.
  591. $(this.el).html(Templates.get("profile.badge-display-case")());
  592. this.mainCaseEl = this.$(".main-case");
  593. this.badgePickerEl = this.$(".badge-picker");
  594. this.editControlEl = this.$(".display-case-cover");
  595. $(this.editControlEl).toggleClass("editable", this.isEditable());
  596. }
  597. this.renderMainCaseContents_();
  598. if (this.fullBadgeList) {
  599. this.renderBadgePicker();
  600. }
  601. this.updateSelectionHighlight();
  602. return this;
  603. }
  604. });