Navigation.js 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. /*
  2. * Copyright (C) 2015 - present Instructure, Inc.
  3. *
  4. * This file is part of Canvas.
  5. *
  6. * Canvas is free software: you can redistribute it and/or modify it under
  7. * the terms of the GNU Affero General Public License as published by the Free
  8. * Software Foundation, version 3 of the License.
  9. *
  10. * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
  11. * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
  12. * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
  13. * details.
  14. *
  15. * You should have received a copy of the GNU Affero General Public License along
  16. * with this program. If not, see <http://www.gnu.org/licenses/>.
  17. */
  18. import _ from 'underscore'
  19. import $ from 'jquery'
  20. import I18n from 'i18n!new_nav'
  21. import React from 'react'
  22. import Tray from 'react-tray'
  23. import CoursesTray from 'jsx/navigation_header/trays/CoursesTray'
  24. import GroupsTray from 'jsx/navigation_header/trays/GroupsTray'
  25. import AccountsTray from 'jsx/navigation_header/trays/AccountsTray'
  26. import ProfileTray from 'jsx/navigation_header/trays/ProfileTray'
  27. import HelpTray from 'jsx/navigation_header/trays/HelpTray'
  28. import SVGWrapper from 'jsx/shared/SVGWrapper'
  29. import preventDefault from 'compiled/fn/preventDefault'
  30. import parseLinkHeader from 'compiled/fn/parseLinkHeader'
  31. var EXTERNAL_TOOLS_REGEX = /^\/accounts\/[^\/]*\/(external_tools)/;
  32. var ACTIVE_ROUTE_REGEX = /^\/(courses|groups|accounts|grades|calendar|conversations|profile)/;
  33. var ACTIVE_CLASS = 'ic-app-header__menu-list-item--active';
  34. var UNREAD_COUNT_POLL_INTERVAL = 60000 // 60 seconds
  35. var TYPE_URL_MAP = {
  36. courses: '/api/v1/users/self/favorites/courses?include[]=term&exclude[]=enrollments',
  37. groups: '/api/v1/users/self/groups?include[]=can_access',
  38. accounts: '/api/v1/accounts',
  39. help: '/help_links'
  40. };
  41. const TYPE_FILTER_MAP = {
  42. groups: group => group.can_access && !group.concluded
  43. };
  44. const RESOURCE_COUNT = 10;
  45. var Navigation = React.createClass({
  46. displayName: 'Navigation',
  47. getInitialState () {
  48. return {
  49. groups: [],
  50. accounts: [],
  51. courses: [],
  52. help: [],
  53. unread_count: 0,
  54. unread_count_attempts: 0,
  55. isTrayOpen: false,
  56. type: null,
  57. coursesLoading: false,
  58. coursesAreLoaded: false,
  59. accountsLoading: false,
  60. accountsAreLoaded: false,
  61. groupsLoading: false,
  62. groupsAreLoaded: false,
  63. helpLoading: false,
  64. helpAreLoaded: false
  65. };
  66. },
  67. componentWillMount () {
  68. /**
  69. * Mount up stuff to our existing DOM elements, yes, it's not very
  70. * React-y, but it is workable and maintainable, plus it doesn't require
  71. * us to trash what Rails has already rendered.
  72. */
  73. //////////////////////////////////
  74. /// Hover Events
  75. //////////////////////////////////
  76. _.forEach(TYPE_URL_MAP, (url, type) => {
  77. $(`#global_nav_${type}_link`).one('mouseover', () => {
  78. this.getResource(url, type);
  79. });
  80. });
  81. //////////////////////////////////
  82. /// Click Events
  83. //////////////////////////////////
  84. ['courses', 'groups', 'accounts', 'profile', 'help'].forEach((type) => {
  85. $(`#global_nav_${type}_link`).on('click', preventDefault(this.handleMenuClick.bind(this, type)));
  86. });
  87. },
  88. componentDidMount () {
  89. if (this.state.unread_count_attempts == 0) {
  90. if (window.ENV.current_user_id &&
  91. !window.ENV.current_user_disabled_inbox &&
  92. this.unreadCountElement().length != 0 &&
  93. !(window.ENV.current_user &&
  94. window.ENV.current_user.fake_student)) {
  95. this.pollUnreadCount();
  96. }
  97. }
  98. },
  99. /**
  100. * Given a URL and a type value, it gets the data and updates state.
  101. */
  102. getResource (url, type) {
  103. var loadingState = {};
  104. loadingState[`${type}Loading`] = true;
  105. this.setState(loadingState);
  106. this.loadResourcePage(url, type);
  107. },
  108. loadResourcePage (url, type, previousData = []) {
  109. $.getJSON(url, (data, __, xhr) => {
  110. const newData = previousData.concat(this.filterDataForType(data, type));
  111. // queue the next page if we need one
  112. if (newData.length < RESOURCE_COUNT) {
  113. const link = parseLinkHeader(xhr);
  114. if (link.next) {
  115. this.loadResourcePage(link.next, type, newData);
  116. return;
  117. }
  118. }
  119. // finished
  120. let newState = {};
  121. newState[type] = newData;
  122. newState[`${type}Loading`] = false;
  123. newState[`${type}AreLoaded`] = true;
  124. this.setState(newState);
  125. });
  126. },
  127. filterDataForType (data, type) {
  128. const filterFunc = TYPE_FILTER_MAP[type];
  129. if (typeof filterFunc === 'function') {
  130. return data.filter(filterFunc);
  131. }
  132. return data;
  133. },
  134. pollUnreadCount () {
  135. this.setState({unread_count_attempts: this.state.unread_count_attempts + 1}, function () {
  136. if (this.state.unread_count_attempts <= 5) {
  137. $.ajax('/api/v1/conversations/unread_count')
  138. .then((data) => this.updateUnreadCount(data.unread_count))
  139. .then(null, console.log.bind(console, 'something went wrong updating unread count'))
  140. .always(() => setTimeout(this.pollUnreadCount, this.state.unread_count_attempts * UNREAD_COUNT_POLL_INTERVAL));
  141. }
  142. });
  143. },
  144. unreadCountElement () {
  145. return this.$unreadCount || (this.$unreadCount = $('#global_nav_conversations_link').find('.menu-item__badge'))
  146. },
  147. updateUnreadCount (count) {
  148. count = parseInt(count, 10);
  149. this.unreadCountElement().text(I18n.n(count));
  150. this.unreadCountElement().toggle(count > 0);
  151. },
  152. componentWillUpdate (newProps, newState) {
  153. if (newState.activeItem !== this.state.activeItem) {
  154. $('.' + ACTIVE_CLASS).removeClass(ACTIVE_CLASS);
  155. $('#global_nav_' + newState.activeItem + '_link').closest('li').addClass(ACTIVE_CLASS);
  156. }
  157. },
  158. determineActiveLink () {
  159. var path = window.location.pathname;
  160. var matchData = path.match(EXTERNAL_TOOLS_REGEX) || path.match(ACTIVE_ROUTE_REGEX);
  161. var activeItem = matchData && matchData[1];
  162. if (!activeItem) {
  163. this.setState({activeItem: 'dashboard'})
  164. } else {
  165. this.setState({activeItem});
  166. }
  167. },
  168. handleMenuClick (type) {
  169. // Make sure data is loaded up
  170. if (TYPE_URL_MAP[type] && !this.state[`${type}AreLoaded`] && !this.state[`${type}Loading`]) {
  171. this.getResource(TYPE_URL_MAP[type], type);
  172. }
  173. if (this.state.isTrayOpen && (this.state.activeItem === type)) {
  174. this.closeTray();
  175. } else if (this.state.isTrayOpen && (this.state.activeItem !== type)) {
  176. this.openTray(type);
  177. } else {
  178. this.openTray(type);
  179. }
  180. },
  181. openTray (type) {
  182. this.setState({
  183. type: type,
  184. isTrayOpen: true,
  185. activeItem: type
  186. });
  187. },
  188. closeTray () {
  189. this.determineActiveLink();
  190. this.setState({
  191. isTrayOpen: false
  192. }, function () {
  193. setTimeout(() => {
  194. this.setState({
  195. type: null
  196. });
  197. }, 150);
  198. });
  199. },
  200. renderTrayContent () {
  201. switch (this.state.type) {
  202. case 'courses':
  203. return (
  204. <CoursesTray
  205. courses={this.state.courses}
  206. hasLoaded={this.state.coursesAreLoaded}
  207. closeTray={this.closeTray}
  208. />
  209. );
  210. case 'groups':
  211. return (
  212. <GroupsTray
  213. groups={this.state.groups}
  214. hasLoaded={this.state.groupsAreLoaded}
  215. closeTray={this.closeTray}
  216. />
  217. );
  218. case 'accounts':
  219. return (
  220. <AccountsTray
  221. accounts={this.state.accounts}
  222. hasLoaded={this.state.accountsAreLoaded}
  223. closeTray={this.closeTray}
  224. />
  225. );
  226. case 'profile':
  227. return (
  228. <ProfileTray
  229. userDisplayName={window.ENV.current_user.display_name}
  230. userAvatarURL={window.ENV.current_user.avatar_image_url}
  231. profileEnabled={window.ENV.SETTINGS.enable_profiles}
  232. eportfoliosEnabled={window.ENV.SETTINGS.eportfolios_enabled}
  233. closeTray={this.closeTray}
  234. />
  235. );
  236. case 'help':
  237. return (
  238. <HelpTray
  239. trayTitle={window.ENV.help_link_name}
  240. links={this.state.help}
  241. hasLoaded={this.state.helpAreLoaded}
  242. closeTray={this.closeTray}
  243. />
  244. );
  245. default:
  246. return null;
  247. }
  248. },
  249. render () {
  250. return (
  251. <Tray
  252. isOpen={this.state.isTrayOpen}
  253. onBlur={this.closeTray}
  254. closeTimeoutMS={400}
  255. getAriaHideElement={() => $('#application')[0]}
  256. getElementToFocus={() => $('.ReactTray__Content')[0]}
  257. >
  258. {this.renderTrayContent()}
  259. </Tray>
  260. );
  261. }
  262. });
  263. export default Navigation