diff --git a/lms/djangoapps/teams/static/teams/js/models/topic.js b/lms/djangoapps/teams/static/teams/js/models/topic.js new file mode 100644 index 0000000000..ad1afb8115 --- /dev/null +++ b/lms/djangoapps/teams/static/teams/js/models/topic.js @@ -0,0 +1,17 @@ +/** + * Model for a topic. + */ +(function (define) { + 'use strict'; + define(['backbone'], function (Backbone) { + var Topic = Backbone.Model.extend({ + defaults: { + name: '', + description: '', + team_count: 0, + id: '' + } + }); + return Topic; + }) +}).call(this, define || RequireJS.define); diff --git a/lms/djangoapps/teams/static/teams/js/spec/topic_card_spec.js b/lms/djangoapps/teams/static/teams/js/spec/topic_card_spec.js new file mode 100644 index 0000000000..e6153765cf --- /dev/null +++ b/lms/djangoapps/teams/static/teams/js/spec/topic_card_spec.js @@ -0,0 +1,37 @@ +define(['jquery', + 'underscore', + 'teams/js/views/topic_card', + 'teams/js/models/topic'], + function ($, _, TopicCardView, Topic) { + + describe('topic card view', function () { + var view; + + beforeEach(function () { + spyOn(TopicCardView.prototype, 'action'); + view = new TopicCardView({ + model: new Topic({ + 'id': 'renewables', + 'name': 'Renewable Energy', + 'description': 'Explore how changes in <ⓡⓔⓝⓔⓦⓐⓑⓛⓔ> ʎƃɹǝuǝ will affect our lives.', + 'team_count': 34 + }), + }); + }); + + it('can render itself', function () { + expect(view.$el).toHaveClass('square-card'); + expect(view.$el.find('.card-title').text()).toContain('Renewable Energy'); + expect(view.$el.find('.card-description').text()).toContain('changes in <ⓡⓔⓝⓔⓦⓐⓑⓛⓔ> ʎƃɹǝuǝ'); + expect(view.$el.find('.card-meta-details').text()).toContain('34 Teams'); + expect(view.$el.find('.action').text()).toContain('View'); + }); + + it('navigates when action button is clicked', function () { + view.$el.find('.action').trigger('click'); + // TODO test actual navigation once implemented + expect(view.action).toHaveBeenCalled(); + }); + }); + } +); diff --git a/lms/djangoapps/teams/static/teams/js/views/topic_card.js b/lms/djangoapps/teams/static/teams/js/views/topic_card.js new file mode 100644 index 0000000000..165a407711 --- /dev/null +++ b/lms/djangoapps/teams/static/teams/js/views/topic_card.js @@ -0,0 +1,49 @@ +/** + * View for a topic card. Displays a Topic. + */ +;(function (define) { + 'use strict'; + define(['backbone', 'underscore', 'gettext', 'js/components/card/views/card'], + function (Backbone, _, gettext, CardView) { + var TeamCountDetailView = Backbone.View.extend({ + tagName: 'p', + className: 'team-count', + + initialize: function () { + this.render(); + }, + + render: function () { + var team_count = this.model.get('team_count'); + this.$el.html(_.escape(interpolate( + ngettext('%(team_count)s Team', '%(team_count)s Teams', team_count), + {team_count: team_count}, + true + ))); + return this; + } + }); + + var TopicCardView = CardView.extend({ + initialize: function () { + this.detailViews = [new TeamCountDetailView({ model: this.model })]; + CardView.prototype.initialize.apply(this, arguments); + }, + + action: function (event) { + event.preventDefault(); + // TODO implement actual navigation + }, + + configuration: 'square_card', + cardClass: 'topic-card', + title: function () { return this.model.get('name'); }, + description: function () { return this.model.get('description'); }, + details: function () { return this.detailViews; }, + actionClass: 'action-view', + actionContent: _.escape(gettext('View')) + ' ' + }); + + return TopicCardView; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/components/card/views/card.js b/lms/static/js/components/card/views/card.js new file mode 100644 index 0000000000..afa70987ea --- /dev/null +++ b/lms/static/js/components/card/views/card.js @@ -0,0 +1,99 @@ +/** + * A generic card view class. + * + * Subclasses can override any of the following: + * - configuration (string or function): Sets the display style of the card as square or list. Can take values of + * "square_card" or "list_card". Defaults to "square_card". + * - action (function): Action to take when the action button is clicked. Defaults to a no-op. + * - cardClass (string or function): Class name for this card's DOM element. Defaults to the empty string. + * - title (string or function): Title of the card. Defaults to the empty string. + * - description (string or function): Description of the card. Defaults to the empty string. + * - details (array or function): Array of child views to be rendered as details of this card. The class "meta-detail" + * is automatically added to each rendered child view to ensure appropriate styling. Defaults to an empty list. + * - actionClass (string or function): Class name for the action DOM element. Defaults to the empty string. + * - actionUrl (string or function): URL to navigate to when the action button is clicked. Defaults to the empty string. + * - actionContent: Content of the action button. This may include HTML. Default to the empty string. + */ +;(function (define) { + 'use strict'; + define(['jquery', + 'backbone', + 'text!templates/components/card/square_card.underscore', + 'text!templates/components/card/list_card.underscore'], + function ($, Backbone, squareCardTemplate, listCardTemplate) { + var CardView = Backbone.View.extend({ + events: { + 'click .action' : 'action' + }, + + /** + * constructor is needed in addition to initialize because of Backbone's initialization order. + * initialize seems to run last in the initialization process, after className. However, className + * depends on this.configuration being set to pick the appropriate class. Therefore, configuration + * is set in the constructor, but the rest of the initialization happens in initialize. + */ + constructor: function (options) { + if (!this.configuration) { + this.configuration = (options && options.configuration) ? options.configuration : 'square_card'; + } + Backbone.View.prototype.constructor.apply(this, arguments); + }, + + initialize: function () { + this.template = this.switchOnConfiguration( + _.template(squareCardTemplate), + _.template(listCardTemplate) + ); + this.render(); + }, + + switchOnConfiguration: function (square_result, list_result) { + return this.callIfFunction(this.configuration) === 'square_card' ? + square_result : list_result; + }, + + callIfFunction: function (value) { + if ($.isFunction(value)) { + return value.call(this); + } else { + return value; + } + }, + + className: function () { + return 'card ' + + this.switchOnConfiguration('square-card', 'list-card') + + ' ' + this.callIfFunction(this.cardClass); + }, + + render: function () { + this.$el.html(this.template({ + title: this.callIfFunction(this.title), + description: this.callIfFunction(this.description), + action_class: this.callIfFunction(this.actionClass), + action_url: this.callIfFunction(this.actionUrl), + action_content: this.callIfFunction(this.actionContent) + })); + var detailsEl = this.$el.find('.card-meta-details'); + _.each(this.callIfFunction(this.details), function (detail) { + // Call setElement to rebind event handlers + detail.setElement(detail.el).render(); + detail.$el.addClass('meta-detail'); + detailsEl.append(detail.el); + }); + return this; + }, + + action: function () { }, + cardClass: '', + title: '', + description: '', + details: [], + actionClass: '', + actionUrl: '', + actionContent: '' + }); + + return CardView; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/spec/components/card/card_spec.js b/lms/static/js/spec/components/card/card_spec.js new file mode 100644 index 0000000000..f7ed92ea0a --- /dev/null +++ b/lms/static/js/spec/components/card/card_spec.js @@ -0,0 +1,73 @@ +(function (define) { + 'use strict'; + + define(['jquery', + 'underscore', + 'backbone', + 'js/components/card/views/card' + ], + function($, _, Backbone, CardView) { + + describe('card component view', function () { + it('can render itself as a square card', function () { + var view = new CardView({ configuration: 'square_card' }); + expect(view.$el).toHaveClass('square-card'); + expect(view.$el.find('.card-meta-wrapper .action').length).toBe(1); + }); + + it('can render itself as a list card', function () { + var view = new CardView({ configuration: 'list_card' }); + expect(view.$el).toHaveClass('list-card'); + expect(view.$el.find('.card-core-wrapper .action').length).toBe(1); + }); + + it('can render child views', function () { + var testChildView = new (Backbone.View.extend({ className: 'test-view' }))(); + spyOn(testChildView, 'render'); + var view = new (CardView.extend({ details: [testChildView] }))(); + expect(testChildView.render).toHaveBeenCalled(); + expect(view.$el.find('.test-view')).toHaveClass('meta-detail'); + }); + + it('calls action when clicked', function () { + spyOn(CardView.prototype, 'action'); + var view = new CardView({ configuration: 'square_card' }); + view.$el.find('.action').trigger('click'); + expect(view.action).toHaveBeenCalled(); + }); + + var verifyContent = function (view) { + expect(view.$el).toHaveClass('test-card'); + expect(view.$el.find('.card-title').text()).toContain('A test title'); + expect(view.$el.find('.card-description').text()).toContain('A test description'); + expect(view.$el.find('.action')).toHaveClass('test-action'); + expect(view.$el.find('.action')).toHaveAttr('href', 'www.example.com'); + expect(view.$el.find('.action').text()).toContain('A test action'); + }; + + it('can have strings for cardClass, title, description, and action', function () { + var view = new (CardView.extend({ + cardClass: 'test-card', + title: 'A test title', + description: 'A test description', + actionClass: 'test-action', + actionUrl: 'www.example.com', + actionContent: 'A test action' + }))(); + verifyContent(view); + }); + + it('can have functions for cardClass, title, description, and action', function () { + var view = new (CardView.extend({ + cardClass: function () { return 'test-card'; }, + title: function () { return 'A test title'; }, + description: function () { return 'A test description'; }, + actionClass: function () { return 'test-action'; }, + actionUrl: function () { return 'www.example.com'; }, + actionContent: function () { return 'A test action'; } + })); + verifyContent(view); + }); + }); + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 43cc377909..68d9be3edf 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -583,8 +583,10 @@ define([ // Run the LMS tests 'lms/include/teams/js/spec/teams_factory_spec.js', + 'lms/include/teams/js/spec/topic_card_spec.js', 'lms/include/js/spec/components/header/header_spec.js', 'lms/include/js/spec/components/tabbed/tabbed_view_spec.js', + 'lms/include/js/spec/components/card/card_spec.js', 'lms/include/js/spec/photocapture_spec.js', 'lms/include/js/spec/staff_debug_actions_spec.js', 'lms/include/js/spec/views/notification_spec.js', diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index 95aa51b64c..8470fc026f 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -93,11 +93,13 @@ fixture_paths: - templates/file-upload.underscore - templates/components/header - templates/components/tabbed + - templates/components/card - js/fixtures/edxnotes - js/fixtures/search - templates/search - templates/discovery - common/templates + - teams/templates requirejs: paths: diff --git a/lms/templates/components/card/list_card.underscore b/lms/templates/components/card/list_card.underscore new file mode 100644 index 0000000000..3bbfc272cb --- /dev/null +++ b/lms/templates/components/card/list_card.underscore @@ -0,0 +1,13 @@ +
+
+

<%- title %>

+

<%- description %>

+
+
+ <%= action_content %> +
+
+
+
+
+
diff --git a/lms/templates/components/card/square_card.underscore b/lms/templates/components/card/square_card.underscore new file mode 100644 index 0000000000..252ba5854e --- /dev/null +++ b/lms/templates/components/card/square_card.underscore @@ -0,0 +1,13 @@ +
+
+

<%- title %>

+

<%- description %>

+
+
+
+
+
+
+ <%= action_content %> +
+