diff --git a/casepro/pods/base.py b/casepro/pods/base.py index 548259ed0..8718c0997 100644 --- a/casepro/pods/base.py +++ b/casepro/pods/base.py @@ -32,12 +32,59 @@ def config_json(self): return json.dumps(self.config._config_data) def read_data(self, params): - """Should return the data that should be used to create the display for the pod.""" + """ + Should return the data that should be used to create the display for the pod. + + For the base implementation, the data should be an object with 'items' and 'actions' keys. + + The items key should be a list of objects, that have 'name' and 'value' keys, with the value of the keys being + what will be displayed. + + The 'actions' key should be a list of objects, that have 'type', 'name' and 'payload' keys, where type and + payload is what is sent to the 'perform_action' function to determine which button has been pressed, and 'name' + is the text that is displayed on the button. + + Each action may include the following optional fields: + - ``busy_text``: used as the action's corresponding + button's text while waiting on a response from the pod's api side + when the action is triggered. Defaults to the value of the ``name`` + field. + - ``confirm``: whether a confirmation modal should be shown to + confirm whether the user would like to perform the action. Defaults + to ``false``. + + Example: + { + 'items': [ + { + 'name': 'EDD', + 'value': '2015-07-18', + }, + ], + 'actions': [ + { + 'type': 'remove_edd', + 'name': 'Remove EDD', + 'payload': {}, + 'busy_text': 'Removing EDD', + 'confirm': True + }, + ], + } + """ return {} - def perform_action(self, params): - """Should perform the action specified by params.""" - return {} + def perform_action(self, type_, params): + """ + Should perform the action specified by the type and params (which are specified in the read function). + + Returns a tuple (success, payload), where 'success' is a boolean value indicating whether the action was + successful or not. If true, a case action note will be created. + + For the base implementation, payload is an object with a 'message' key, which is the error message if success + is false, or the message to place in the case action note if success is true. + """ + return (False, {'message': ''}) class PodPlugin(AppConfig): diff --git a/casepro/pods/tests/test_views.py b/casepro/pods/tests/test_views.py index 51827926c..4eb4290f8 100644 --- a/casepro/pods/tests/test_views.py +++ b/casepro/pods/tests/test_views.py @@ -3,6 +3,7 @@ from django.core.urlresolvers import reverse from django.test import modify_settings +from casepro.cases.models import CaseAction from casepro.test import BaseCasesTest @@ -13,6 +14,17 @@ class ViewPodDataView(BaseCasesTest): """ Tests relating to the view_pod_data view. """ + def setUp(self): + super(ViewPodDataView, self).setUp() + contact = self.create_contact(self.unicef, 'contact-uuid', 'contact_name') + msg = self.create_message(self.unicef, 0, contact, 'Test message') + self.case = self.create_case(self.unicef, contact, self.moh, msg) + self.login(self.admin) + + with self.settings(PODS=[{'label': 'base_pod'}]): + from casepro.pods import registry + reload(registry) + def test_invalid_method(self): """ If the request method is not GET, an appropriate error should be @@ -30,11 +42,9 @@ def test_pod_doesnt_exist(self): If the requested pod id is invalid, an appropriate 404 error should be returned. """ - with self.settings(PODS=[]): - from casepro.pods import registry - reload(registry) response = self.url_get( - 'unicef', reverse('read_pod_data', args=('0',))) + 'unicef', reverse('read_pod_data', args=('1',)), params={'case_id': self.case.id}) + self.assertEqual(response.status_code, 404) self.assertEqual(response.json, { 'reason': 'Pod does not exist'} @@ -44,14 +54,53 @@ def test_pod_valid_request(self): """ If it is a valid get request, the data from the pod should be returned. """ - with self.settings(PODS=[{'label': 'base_pod'}]): - from casepro.pods import registry - reload(registry) response = self.url_get( - 'unicef', reverse('read_pod_data', args=('0',))) + 'unicef', reverse('read_pod_data', args=('0',)), params={'case_id': self.case.id}) + self.assertEqual(response.status_code, 200) self.assertEqual(response.json, {}) + def test_case_id_required(self): + ''' + If the case id is not present in the request, an error response should be returned. + ''' + response = self.url_get( + 'unicef', reverse('read_pod_data', args=('0',))) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json, { + 'reason': 'Request needs "case_id" query parameter' + }) + + def test_case_not_found(self): + ''' + If the case is not found, an error response should be returned. + ''' + response = self.url_get( + 'unicef', reverse('read_pod_data', args=('0',)), + params={'case_id': 23}) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json, { + 'reason': 'Case with id 23 not found' + }) + + def test_unauthorized(self): + ''' + If the user does not have read permission, the request should be denied. + ''' + self.login(self.user4) + + response = self.url_get( + 'unicef', reverse('read_pod_data', args=('0',)), + params={'case_id': self.case.id}) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json, { + 'reason': ( + "The request's authentication details do not corresond " + "to the required access level for accessing this resource") + }) + @modify_settings(INSTALLED_APPS={ 'append': 'casepro.pods.PodPlugin', @@ -60,6 +109,19 @@ class PerformPodActionView(BaseCasesTest): """ Tests relating to the perform_pod_action view. """ + def setUp(self): + super(PerformPodActionView, self).setUp() + contact = self.create_contact(self.unicef, 'contact-uuid', 'contact_name') + msg = self.create_message(self.unicef, 0, contact, 'Test message') + self.case = self.create_case(self.unicef, contact, self.moh, msg) + self.login(self.admin) + + CaseAction.objects.all().delete() + + with self.settings(PODS=[{'label': 'base_pod'}]): + from casepro.pods import registry + reload(registry) + def test_invalid_method(self): """ If the request method is not POST, an appropriate error should be @@ -77,11 +139,8 @@ def test_pod_doesnt_exist(self): If the requested pod id is invalid, an appropriate 404 error should be returned. """ - with self.settings(PODS=[]): - from casepro.pods import registry - reload(registry) response = self.url_post_json( - 'unicef', reverse('perform_pod_action', args=('0',)), {}) + 'unicef', reverse('perform_pod_action', args=('23',)), {'case_id': self.case.id}) self.assertEqual(response.status_code, 404) self.assertEqual(response.json, { 'reason': 'Pod does not exist'} @@ -100,14 +159,103 @@ def test_invalid_json(self): 'details': 'No JSON object could be decoded' }) + def test_case_id_required(self): + ''' + If the case id is not present in the request, an error response should be returned. + ''' + response = self.url_post_json( + 'unicef', reverse('perform_pod_action', args=('0',)), {}) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json, { + 'reason': 'Request object needs to have a "case_id" field' + }) + def test_pod_valid_request(self): """ If it is a valid post request, the action should be performed. """ - with self.settings(PODS=[{'label': 'base_pod'}]): + response = self.url_post_json( + 'unicef', reverse('perform_pod_action', args=('0',)), {'case_id': self.case.id}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json, { + 'success': False, + 'payload': { + 'message': '', + } + }) + + @modify_settings(INSTALLED_APPS={ + 'append': 'casepro.pods.tests.utils.SuccessActionPlugin', + }) + def test_case_action_note_created_on_successful_action(self): + ''' + If the action is successful, a case action note should be created. + ''' + with self.settings(PODS=[{'label': 'success_pod'}]): from casepro.pods import registry reload(registry) + response = self.url_post_json( - 'unicef', reverse('perform_pod_action', args=('0',)), {}) + 'unicef', reverse('perform_pod_action', args=('0',)), { + 'case_id': self.case.id, + 'action': { + 'type': 'foo', + 'payload': {'foo': 'bar'}, + }, + }) + self.assertEqual(response.status_code, 200) - self.assertEqual(response.json, {}) + + message = "Type foo Params {u'foo': u'bar'}" + self.assertEqual(response.json, { + 'success': True, + 'payload': { + 'message': message + } + }) + + [caseaction] = CaseAction.objects.all() + self.assertEqual( + caseaction.note, + "%s %s" % (self.admin.username, message)) + + def test_case_not_found(self): + ''' + If the case is not found, an error response should be returned. + ''' + + response = self.url_post_json( + 'unicef', reverse('perform_pod_action', args=('0',)), { + 'case_id': 23, + 'action': { + 'type': 'foo', + 'payload': {'foo': 'bar'}, + }, + }) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json, { + 'reason': 'Case with id 23 not found' + }) + + def test_unauthorized(self): + ''' + If the user does not have update permission, the request should be denied. + ''' + self.login(self.user4) + + response = self.url_post_json( + 'unicef', reverse('perform_pod_action', args=('0',)), { + 'case_id': self.case.id, + 'action': { + 'type': 'foo', + 'payload': {'foo': 'bar'}, + }, + }) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json, { + 'reason': ( + "The request's authentication details do not corresond " + "to the required access level for accessing this resource") + }) diff --git a/casepro/pods/tests/utils.py b/casepro/pods/tests/utils.py index a2faefaa0..faac5e783 100644 --- a/casepro/pods/tests/utils.py +++ b/casepro/pods/tests/utils.py @@ -16,3 +16,16 @@ class DummyPodPlugin(PodPlugin): directive = 'dummy-pod' scripts = ('dummy-script.js',) styles = ('dummy-style.css',) + + +class SuccessActionPod(Pod): + def perform_action(self, type_, params): + ''' + Returns a successful action result with a message containing the type and params. + ''' + return (True, {'message': 'Type %s Params %r' % (type_, params)}) + + +class SuccessActionPlugin(DummyPodPlugin): + pod_class = SuccessActionPod + label = 'success_pod' diff --git a/casepro/pods/views.py b/casepro/pods/views.py index aa16dab86..a4902595d 100644 --- a/casepro/pods/views.py +++ b/casepro/pods/views.py @@ -3,14 +3,30 @@ import json from django.http import JsonResponse +from casepro.cases.models import Case, CaseAction, AccessLevel from casepro.pods import registry +ACTION_NOTE_CONTENT = "%(username)s %(message)s" + def read_pod_data(request, index): """Delegates to the `read_data` function of the correct pod.""" if request.method != 'GET': return JsonResponse({'reason': 'Method not allowed'}, status=405) + case_id = request.GET.get('case_id') + if case_id is None: + return JsonResponse(status=400, data={ + 'reason': 'Request needs "case_id" query parameter' + }) + + case = get_case(case_id) + if case is None: + return case_not_found_response(case_id) + + if not has_case_access(request.user, case, AccessLevel.read): + return authorization_failure_response() + try: pod = registry.pods[int(index)] except IndexError: @@ -20,7 +36,10 @@ def read_pod_data(request, index): def perform_pod_action(request, index): - """Deletegates to the `perform_action` function of the correct pod.""" + """ + Delegates to the `perform_action` function of the correct pod. If the action completes successfully, a new case + action note is created with the success message. + """ if request.method != 'POST': return JsonResponse({'reason': 'Method not allowed'}, status=405) @@ -34,4 +53,47 @@ def perform_pod_action(request, index): except ValueError as e: return JsonResponse({'reason': 'JSON decode error', 'details': e.message}, status=400) - return JsonResponse(pod.perform_action(data)) + case_id = data.get('case_id') + if case_id is None: + return JsonResponse( + {'reason': 'Request object needs to have a "case_id" field'}, status=400) + + case = get_case(case_id) + if case is None: + return case_not_found_response(case_id) + + if not has_case_access(request.user, case, AccessLevel.update): + return authorization_failure_response() + + action_data = data.get('action', {}) + success, payload = pod.perform_action(action_data.get('type'), action_data.get('payload', {})) + if success is True: + note = ACTION_NOTE_CONTENT % { + 'username': request.user.username, + 'message': payload.get('message'), + } + CaseAction.create(case, request.user, CaseAction.ADD_NOTE, note=note) + + return JsonResponse({'success': success, 'payload': payload}) + + +def has_case_access(user, case, level): + return case.access_level(user) >= level + + +def get_case(case_id): + return Case.objects.filter(id=case_id).first() + + +def case_not_found_response(id): + return JsonResponse(status=404, data={ + 'reason': "Case with id %s not found" % id + }) + + +def authorization_failure_response(): + return JsonResponse(status=403, data={ + 'reason': ( + "The request's authentication details do not corresond " + "to the required access level for accessing this resource") + }) diff --git a/karma.conf.coffee b/karma.conf.coffee index f375ce0ec..fe5763ec9 100644 --- a/karma.conf.coffee +++ b/karma.conf.coffee @@ -22,6 +22,7 @@ module.exports = (config) -> # templates 'static/templates/**/*.html', + 'karma/templates/**/*.html', # the code we are testing 'static/coffee/*.coffee', @@ -47,6 +48,7 @@ module.exports = (config) -> '**/*.coffee': ['coffee'], 'static/**/*.coffee': ['coverage'] 'static/templates/**/*.html': ['ng-html2js'] + 'karma/templates/**/*.html': ['ng-html2js'] } # this makes sure that we get coffeescript line numbers instead @@ -59,8 +61,8 @@ module.exports = (config) -> path.replace /\.js$/, '.coffee' ngHtml2JsPreprocessor: - stripPrefix: 'static' - prependPrefix: '/sitestatic' + stripPrefix: 'static/' + prependPrefix: '/sitestatic/' moduleName: 'templates' # test results reporter to use diff --git a/karma/templates/alerts/dummy-alert.html b/karma/templates/alerts/dummy-alert.html new file mode 100644 index 000000000..047843b9e --- /dev/null +++ b/karma/templates/alerts/dummy-alert.html @@ -0,0 +1,3 @@ + + [[ alert.context.message ]] + diff --git a/karma/templates/modals/dummy-confirm.html b/karma/templates/modals/dummy-confirm.html new file mode 100644 index 000000000..384cb1abb --- /dev/null +++ b/karma/templates/modals/dummy-confirm.html @@ -0,0 +1,12 @@ + + + + + diff --git a/karma/test-controllers.coffee b/karma/test-controllers.coffee index 3b570604a..520162b94 100644 --- a/karma/test-controllers.coffee +++ b/karma/test-controllers.coffee @@ -93,6 +93,14 @@ describe('controllers:', () -> expect($scope.contact).toEqual(test.ann) ) + it('should should proxy timelineChanged events from child scopes', (done) -> + child = $scope.$new(false) + sibling = $scope.$new(false) + + sibling.$on('timelineChanged', -> done()) + child.$emit('timelineChanged') + ) + it('addNote', () -> noteModal = spyOnPromise($q, $scope, UtilsService, 'noteModal') addNote = spyOnPromise($q, $scope, CaseService, 'addNote') @@ -134,6 +142,31 @@ describe('controllers:', () -> expect(UtilsService.confirmModal).toHaveBeenCalled() expect(CaseService.unwatch).toHaveBeenCalledWith(test.case1) ) + + it('should should add a alert on alert events', () -> + $scope.alerts = [] + $scope.$emit('alert', {type: 'foo'}) + expect($scope.alerts).toEqual([{type: 'foo'}]) + ) + + it('should should ignore duplicate pod_load_api_failure alerts', () -> + $scope.alerts = [] + + $scope.$emit('alert', {type: 'pod_load_api_failure'}) + expect($scope.alerts).toEqual([{type: 'pod_load_api_failure'}]) + + $scope.$emit('alert', {type: 'pod_load_api_failure'}) + $scope.$emit('alert', {type: 'pod_load_api_failure'}) + expect($scope.alerts).toEqual([{type: 'pod_load_api_failure'}]) + ) + + describe('addAlert', () -> + it('should add the given alert', () -> + $scope.alerts = [] + $scope.addAlert({type: 'foo'}) + expect($scope.alerts).toEqual([{type: 'foo'}]) + ) + ) ) #======================================================================= @@ -729,23 +762,62 @@ describe('controllers:', () -> describe('PodController', () -> $scope = null + PodUIService = null + PodApiService = null + class PodApiServiceError + + bindController = (deps) -> + $controller('PodController', angular.extend({}, deps, { + $scope, + PodApiService, + PodUIService + })) beforeEach(() -> $scope = $rootScope.$new() - ) - describe('init', () -> - it('should attach pod data to the scope', () -> - PodApiService = new class PodApiService - get: -> + $scope.podId = 21 + $scope.caseId = 23 + $scope.podConfig = {title: 'Foo'} + + $scope.podData = { + items: [], + actions: [] + } - spyOn(PodApiService, 'get').and.returnValue($q.resolve({foo: 'bar'})) + PodUIService = new class PodUIService + confirmAction: -> $q.resolve() + alertActionFailure: () -> null + alertActionApiFailure: () -> null + alertLoadApiFailure: () -> null - $controller('PodController', { - $scope - PodApiService + PodApiService = new class PodApiService + PodApiServiceError: PodApiServiceError, + + get: -> $q.resolve({ + items: [], + actions: [] }) + trigger: -> $q.resolve({success: true}) + ) + + describe('init', () -> + it('should fetch and attach pod data to the scope', () -> + spyOn(PodApiService, 'get').and.returnValue($q.resolve({ + items: [{ + name: 'Foo', + value: 'Bar' + }] + actions: [{ + type: 'baz', + name: 'Baz', + payload: {} + }] + })) + + bindController() + $scope.init(21, 23, {title: 'Baz'}) $scope.$apply() @@ -753,7 +825,341 @@ describe('controllers:', () -> expect($scope.caseId).toEqual(23) expect($scope.podConfig).toEqual({title: 'Baz'}) expect(PodApiService.get).toHaveBeenCalledWith(21, 23) - expect($scope.podData).toEqual({foo: 'bar'})) + + expect($scope.podData).toEqual({ + items: [{ + name: 'Foo', + value: 'Bar' + }], + actions: [ + jasmine.objectContaining({ + type: 'baz' + name: 'Baz', + payload: {} + }) + ] + }) + ) + + it("should set the pod status to loading while it is loading", () -> + d = $q.defer() + + spyOn(PodApiService, 'get').and.returnValue(d.promise.then(-> $q.resolve({ + items: [] + actions: [] + }))) + + bindController() + + $scope.init(21, 23, {title: 'Baz'}) + expect($scope.status).toEqual('loading') + + d.resolve() + $scope.$apply() + + expect($scope.status).toEqual('idle') + ) + + it("should set the pod status to loading_failed if loading fails", () -> + spyOn(PodApiService, 'get').and.returnValue($q.reject(new PodApiServiceError(null))) + + bindController() + + $scope.init(21, 23, {title: 'Baz'}) + $scope.$apply() + expect($scope.status).toEqual('loading_failed') + ) + ) + + describe('update', () -> + it('should fetch and update pod data', () -> + $scope.podId = 21 + $scope.caseId = 23 + + spyOn(PodApiService, 'get').and.returnValue($q.resolve({ + items: [{ + name: 'Foo', + value: 'Bar' + }] + actions: [{ + type: 'baz', + name: 'Baz', + payload: {} + }] + })) + + bindController() + + $scope.update() + $scope.$apply() + + expect(PodApiService.get).toHaveBeenCalledWith(21, 23) + + expect($scope.podData).toEqual({ + items: [{ + name: 'Foo', + value: 'Bar' + }], + actions: [ + jasmine.objectContaining({ + type: 'baz' + name: 'Baz', + payload: {} + }) + ] + }) + ) + + it("should default an action's busy text to the action's name", () -> + $scope.podId = 21 + $scope.caseId = 23 + + spyOn(PodApiService, 'get').and.returnValue($q.resolve({ + items: [{ + name: 'Foo', + value: 'Bar' + }] + actions: [{ + type: 'baz', + name: 'Baz', + busy_text: 'Bazzing', + payload: {} + }, { + type: 'quux', + name: 'Quux', + payload: {} + }] + })) + + bindController() + + $scope.update() + $scope.$apply() + + expect(PodApiService.get).toHaveBeenCalledWith(21, 23) + + expect($scope.podData.actions).toEqual([ + jasmine.objectContaining({ + type: 'baz' + busyText: 'Bazzing' + }), + jasmine.objectContaining({ + type: 'quux', + busyText: 'Quux' + }) + ]) + ) + ) + + describe('trigger', () -> + it('should trigger the given action', () -> + $scope.podId = 21 + $scope.caseId = 23 + + bindController() + + spyOn(PodApiService, 'trigger').and.returnValue($q.resolve({ + success: true + })) + + $scope.trigger({ + type: 'grault', + payload: {garply: 'waldo'} + }) + + $scope.$apply() + + expect(PodApiService.trigger) + .toHaveBeenCalledWith(21, 23, 'grault', {garply: 'waldo'}) + ) + + it('should mark the action as busy', () -> + $scope.podData.actions = [{ + type: 'grault' + isBusy: false, + payload: {} + }, { + type: 'fred', + isBusy: false, + payload: {} + }] + + bindController() + + # defer getting new data indefinitely to prevent isBusy being set to + # false when we retrieve new data + spyOn(PodApiService, 'get').and.returnValue($q.defer().promise) + + spyOn(PodApiService, 'trigger').and.returnValue($q.resolve({success: true})) + + $scope.trigger({ + type: 'grault', + payload: {garply: 'waldo'} + }) + + $scope.$apply() + + expect($scope.podData.actions[0].isBusy).toBe(true) + ) + + it('should mark the action as not busy after api failure', () -> + $scope.podData.actions = [{ + type: 'grault' + isBusy: false, + payload: {} + }, { + type: 'fred', + isBusy: false, + payload: {} + }] + + bindController() + + spyOn(PodApiService, 'get') + .and.returnValue($q.reject(new PodApiServiceError(null))) + + $scope.trigger({ + type: 'grault', + payload: {garply: 'waldo'} + }) + + $scope.$apply() + + expect($scope.podData.actions[0].isBusy).toBe(false) + ) + + it('should emit an alert event if unsuccessful', (done) -> + bindController() + + spyOn(PodApiService, 'trigger').and.returnValue($q.resolve({ + success: false, + payload: {message: 'Foo'} + })) + + spyOn(PodUIService, 'alertActionFailure').and.returnValue('fakeResult') + + $scope.trigger({ + type: 'grault', + payload: {garply: 'waldo'} + }) + + $scope.$on('alert', (e, res) -> + expect(res).toEqual('fakeResult') + + expect(PodUIService.alertActionFailure.calls.allArgs()) + .toEqual([['Foo']]) + + done()) + + $scope.$apply() + ) + + it('should emit a timelineChanged event if successful', (done) -> + bindController() + spyOn(PodApiService, 'trigger').and.returnValue($q.resolve({success: true})) + + $scope.trigger('grault', {garply: 'waldo'}) + + $scope.$on('timelineChanged', -> done()) + + $scope.$apply() + ) + + it('should emit an alert if trigger api method fails', (done) -> + bindController() + + spyOn(PodApiService, 'trigger') + .and.returnValue($q.reject(new PodApiServiceError(null))) + + spyOn(PodUIService, 'alertActionApiFailure') + .and.returnValue('fakeResult') + + $scope.trigger({ + type: 'grault', + payload: {garply: 'waldo'} + }) + + $scope.$on('alert', (e, res) -> + expect(res).toEqual('fakeResult') + done()) + + $scope.$apply() + ) + + it('should emit an alert if get api method fails', (done) -> + bindController() + + spyOn(PodApiService, 'get') + .and.returnValue($q.reject(new PodApiServiceError(null))) + + spyOn(PodUIService, 'alertActionApiFailure') + .and.returnValue('fakeResult') + + $scope.trigger({ + type: 'grault', + payload: {garply: 'waldo'} + }) + + $scope.$on('alert', (e, res) -> + expect(res).toEqual('fakeResult') + done()) + + $scope.$apply() + ) + + it('should fetch and attach data to the scope if successful', () -> + bindController() + + spyOn(PodApiService, 'get').and.returnValue($q.resolve({ + items: [{ + name: 'Foo', + value: 'Bar' + }] + actions: [{ + type: 'baz', + name: 'Baz', + payload: {} + }] + })) + + spyOn(PodApiService, 'trigger').and.returnValue($q.resolve({success: true})) + + $scope.trigger({ + type: 'grault', + payload: {garply: 'waldo'} + }) + + $scope.$apply() + + expect($scope.podData).toEqual({ + items: [{ + name: 'Foo', + value: 'Bar' + }], + actions: [ + jasmine.objectContaining({ + type: 'baz' + name: 'Baz', + payload: {} + }) + ] + }) + ) + + it('should show a confirmation model if the action requires it', () -> + bindController() + spyOn(PodUIService, 'confirmAction') + + $scope.trigger({ + type: 'grault', + name: 'Grault', + confirm: true, + payload: {garply: 'waldo'} + }) + + $scope.$apply() + expect(PodUIService.confirmAction.calls.allArgs()).toEqual([['Grault']]) + ) ) ) ) diff --git a/karma/test-directives.coffee b/karma/test-directives.coffee index 49d3ceeeb..402943f19 100644 --- a/karma/test-directives.coffee +++ b/karma/test-directives.coffee @@ -113,7 +113,14 @@ describe('directives:', () -> $compile = _$compile_ $rootScope.podConfig = {title: 'Foo'} - $rootScope.podData = {items: []} + + $rootScope.podData = { + items: [], + actions: [] + } + + $rootScope.status = 'idle' + $rootScope.trigger = -> )) it('should draw the pod items', () -> @@ -154,5 +161,176 @@ describe('directives:', () -> expect(item2.querySelector('.pod-item-value').textContent) .toContain('Corge') ) + + it('should draw the pod actions', -> + $rootScope.podData.actions = [{ + type: 'foo', + name: 'Foo', + busyText: 'Foo', + isBusy: false, + payload: {bar: 'baz'} + }, { + type: 'quux', + name: 'Quux', + busyText: 'Quux', + isBusy: false, + payload: {corge: 'grault'} + }] + + el = $compile('')($rootScope)[0] + $rootScope.$digest() + + action1 = el.querySelectorAll('.pod-action')[0] + action2 = el.querySelectorAll('.pod-action')[1] + + expect(action1.textContent).toContain('Foo') + expect(action2.textContent).toContain('Quux') + ) + + it('should draw busy pod actions', -> + $rootScope.podData.actions = [{ + type: 'baz', + name: 'Baz', + isBusy: true, + busyText: 'Bazzing', + payload: {} + }] + + el = $compile('')($rootScope)[0] + $rootScope.$digest() + + action1 = el.querySelectorAll('.pod-action')[0] + expect(action1.textContent).toContain('Bazzing') + expect(action1.classList.contains('disabled')).toBe(true) + ) + + it('should call trigger() when an action button is clicked', -> + $rootScope.podData.actions = [datum1, datum2] = [{ + type: 'foo', + name: 'Foo', + busyText: 'Foo', + isBusy: false, + payload: {a: 'b'} + }, { + type: 'bar', + name: 'Bar', + busyText: 'Bar', + isBusy: false, + payload: {c: 'd'} + }] + + $rootScope.trigger = jasmine.createSpy('trigger') + + el = $compile('')($rootScope)[0] + $rootScope.$digest() + + action1 = el.querySelectorAll('.pod-action')[0] + action2 = el.querySelectorAll('.pod-action')[1] + + expect($rootScope.trigger).not.toHaveBeenCalledWith(datum1) + + angular.element(action1).triggerHandler('click') + + expect($rootScope.trigger).toHaveBeenCalledWith(datum1) + expect($rootScope.trigger).not.toHaveBeenCalledWith(datum2) + + angular.element(action2).triggerHandler('click') + + expect($rootScope.trigger).toHaveBeenCalledWith(datum2) + ) + + it('should draw whether it is loading', () -> + $rootScope.status = 'loading' + + el = $compile('')($rootScope)[0] + $rootScope.$digest() + + expect(el.textContent).toMatch('Loading') + ) + + it('should draw whether loading has failed', () -> + $rootScope.status = 'loading_failed' + + el = $compile('')($rootScope)[0] + $rootScope.$digest() + + expect(el.textContent).toMatch('Could not load') + ) + ) + + #======================================================================= + # Tests for cpAlert + #======================================================================= + describe('cpAlert', () -> + beforeEach(() -> + $rootScope.alerts = [] + ) + + it('should draw the alert', () -> + template = $compile('Foo') + el = template($rootScope)[0] + $rootScope.$digest() + + alert = el.querySelector('.alert') + expect(alert.classList.contains('alert-danger')).toBe(true) + expect(alert.textContent).toMatch('Foo') + ) + ) + + #======================================================================= + # Tests for cpAlerts + #======================================================================= + describe('cpAlerts', () -> + beforeEach(() -> + $rootScope.alerts = [] + ) + + it('should draw alerts with no template urls', () -> + $rootScope.alerts = [{ + type: 'danger', + message: 'Foo' + }, { + type: 'danger', + message: 'Bar' + }] + + template = $compile(' + + + ') + + el = template($rootScope)[0] + $rootScope.$digest() + + [alert1, alert2] = el.querySelectorAll('.alert') + + expect(alert1.classList.contains('alert-danger')).toBe(true) + expect(alert1.textContent).toMatch('Foo') + + expect(alert2.classList.contains('alert-danger')).toBe(true) + expect(alert2.textContent).toMatch('Bar') + ) + + it('should draw alert with template urls', () -> + $rootScope.alerts = [{ + templateUrl: '/sitestatic/karma/templates/alerts/dummy-alert.html' + context: {message: 'Foo'} + }, { + templateUrl: '/sitestatic/karma/templates/alerts/dummy-alert.html' + context: {message: 'Bar'} + }] + + template = $compile(' + + + ') + + el = template($rootScope)[0] + $rootScope.$digest() + + [alert1, alert2] = el.querySelectorAll('.alert') + expect(alert1.textContent).toMatch('Foo') + expect(alert2.textContent).toMatch('Bar') + ) ) ) diff --git a/karma/test-services.coffee b/karma/test-services.coffee index 7f113c0ab..37df606da 100644 --- a/karma/test-services.coffee +++ b/karma/test-services.coffee @@ -3,14 +3,17 @@ describe('services:', () -> $httpBackend = null $window = null + $rootScope = null test = null beforeEach(() -> + module('templates') module('cases') - inject((_$httpBackend_, _$window_) -> + inject((_$httpBackend_, _$window_, _$rootScope_) -> $httpBackend = _$httpBackend_ $window = _$window_ + $rootScope = _$rootScope_ ) test = { @@ -630,16 +633,294 @@ describe('services:', () -> PodApiService = _PodApiService_ )) - describe('get', () -> - it('gets a pod', () -> - $httpBackend.expectGET('/pods/read/21/?case_id=23') + describe('method', () -> + it('constructs a pod api method', () -> + $httpBackend.expectGET('/pods/foo/21/?bar=23') .respond({foo: 'bar'}) - PodApiService.get(21, 23) + method = PodApiService.method((id, bar) -> { + method: 'GET', + url: "/pods/foo/#{id}/", + params: {bar} + }) + + method(21, 23) .then((res) -> expect(res).toEqual({foo: 'bar'})) $httpBackend.flush() ) + + it('rejects response errors as PodApiServiceErrors', () -> + $httpBackend.expectGET('/pods/foo/') + .respond(500) + + method = PodApiService.method(-> { + method: 'GET', + url: "/pods/foo/" + }) + + method() + .catch((e) -> e) + .then((e) -> expect(e instanceof PodApiService.PodApiServiceError).toBe(true)) + + $httpBackend.flush() + ) + ) + + describe('get', () -> + it('gets a pod', () -> + expect(PodApiService.get.fn(21, 23)).toEqual({ + method: 'GET', + url: "/pods/read/21/", + params: {case_id: 23} + }) + ) + ) + + describe('trigger', () -> + it('triggers an action', () -> + expect(PodApiService.trigger.fn(21, 23, 'foo', {bar: 'baz'})).toEqual({ + method: 'POST', + url: "/pods/action/21/", + data: { + case_id: 23 + action: { + type: 'foo', + payload: {bar: 'baz'} + } + } + }) + ) + ) + ) + + #======================================================================= + # Tests for ModalService + #======================================================================= + describe('ModalService', () -> + ModalService = null + + beforeEach(inject((_ModalService_) -> + ModalService = _ModalService_ + )) + + describe('confirm', () -> + describe('if no template url is given', () -> + it('should draw the modal', () -> + ModalService.confirm({ + title: 'Foo', + prompt: 'Bar?' + }) + + $rootScope.$apply() + + expect(document.querySelector('.modal-title').textContent) + .toMatch('Foo') + + expect(document.querySelector('.modal-body').textContent) + .toMatch('Bar?') + ) + + it('should fulfill if the modal is accepted', () -> + fulfilled = false + + ModalService.confirm({ + title: 'Foo', + prompt: 'Bar?' + }) + .then(-> fulfilled = true) + + $rootScope.$apply() + expect(fulfilled).toBe(false) + + angular.element(document.querySelector('.btn-modal-accept')) + .triggerHandler('click') + + $rootScope.$apply() + expect(fulfilled).toBe(true) + ) + + it('should reject if the modal is cancelled', () -> + rejected = false + + ModalService.confirm({ + title: 'Foo', + prompt: 'Bar?' + }) + .catch(-> rejected = true) + + $rootScope.$apply() + expect(rejected).toBe(false) + + angular.element(document.querySelector('.btn-modal-cancel')) + .triggerHandler('click') + + $rootScope.$apply() + expect(rejected).toBe(true) + ) + ) + + describe('if a template url is given', () -> + it('should draw the modal', () -> + ModalService.confirm({ + templateUrl: '/sitestatic/karma/templates/modals/dummy-confirm.html', + context: {title: 'Foo'} + }) + + $rootScope.$apply() + + expect(document.querySelector('.modal-title').textContent) + .toMatch('Foo') + + expect(document.querySelector('.modal-body').textContent) + .toMatch('Are you sure you want to do this?') + ) + + it('should fulfill if the modal is accepted', () -> + fulfilled = false + + ModalService.confirm({ + templateUrl: '/sitestatic/karma/templates/modals/dummy-confirm.html', + context: {title: 'Foo'} + }) + .then(-> fulfilled = true) + + $rootScope.$apply() + expect(fulfilled).toBe(false) + + angular.element(document.querySelector('.btn-modal-accept')) + .triggerHandler('click') + + $rootScope.$apply() + expect(fulfilled).toBe(true) + ) + + it('should reject if the modal is cancelled', () -> + rejected = false + + ModalService.confirm({ + templateUrl: '/sitestatic/karma/templates/modals/dummy-confirm.html', + context: {title: 'Foo'} + }) + .catch(-> rejected = true) + + $rootScope.$apply() + expect(rejected).toBe(false) + + angular.element(document.querySelector('.btn-modal-cancel')) + .triggerHandler('click') + + $rootScope.$apply() + expect(rejected).toBe(true) + ) + ) + ) + ) + + #======================================================================= + # Tests for PodUIService + #======================================================================= + describe('PodUIService', () -> + $compile = null + PodUIService = null + + beforeEach(inject((_$compile_, _PodUIService_) -> + $compile = _$compile_ + PodUIService = _PodUIService_ + )) + + describe('confirmAction', () -> + it('should draw the modal', () -> + PodUIService.confirmAction('Foo') + + $rootScope.$apply() + + expect(document.querySelector('.modal-title').textContent) + .toMatch('Foo') + ) + + it('should fulfill if the modal is accepted', () -> + fulfilled = false + + PodUIService.confirmAction('Foo') + .then(-> fulfilled = true) + + $rootScope.$apply() + expect(fulfilled).toBe(false) + + angular.element(document.querySelector('.btn-modal-accept')) + .triggerHandler('click') + + $rootScope.$apply() + expect(fulfilled).toBe(true) + ) + + it('should reject if the modal is cancelled', () -> + rejected = false + + PodUIService.confirmAction('Foo') + .catch(-> rejected = true) + + $rootScope.$apply() + expect(rejected).toBe(false) + + angular.element(document.querySelector('.btn-modal-cancel')) + .triggerHandler('click') + + $rootScope.$apply() + expect(rejected).toBe(true) + ) + ) + + describe('alertActionFailure', () -> + it('should draw the alert', () -> + $rootScope.alerts = [PodUIService.alertActionFailure('Foo')] + + template = $compile(' + + + ') + + el = template($rootScope)[0] + $rootScope.$digest() + + alert = el.querySelector('.alert') + expect(alert.textContent).toMatch('Foo') + ) + ) + + describe('alertActionApiFailure', () -> + it('should draw the alert', () -> + $rootScope.alerts = [PodUIService.alertActionApiFailure()] + + template = $compile(' + + + ') + + el = template($rootScope)[0] + $rootScope.$digest() + + alert = el.querySelector('.alert') + expect(alert.textContent).toMatch('action') + ) + ) + + describe('alertLoadApiFailure', () -> + it('should draw the alert', () -> + $rootScope.alerts = [PodUIService.alertLoadApiFailure()] + + template = $compile(' + + + ') + + el = template($rootScope)[0] + $rootScope.$digest() + + alert = el.querySelector('.alert') + expect(alert.textContent).toMatch('load') + ) ) ) ) diff --git a/karma/test-utils.coffee b/karma/test-utils.coffee index 26c6eae58..245623644 100644 --- a/karma/test-utils.coffee +++ b/karma/test-utils.coffee @@ -46,4 +46,19 @@ describe('utils:', () -> expect(utils.find(items, 'bar', "Z")).toEqual({foo: 5, bar: "Z"}) ) ) + + describe('trap', () -> + it('should call the accept function for values of the given type', () -> + class Foo + foo = new Foo() + expect(utils.trap(Foo, ((v) -> v), (-> null))(foo)).toEqual(foo) + ) + + it('should call the reject function for values not of the given type', () -> + class Foo + class Bar + bar = new Bar() + expect(utils.trap(Foo, ((v) -> v), (-> null))(bar)).toEqual(null) + ) + ) ) diff --git a/static/coffee/controllers.coffee b/static/coffee/controllers.coffee index 721147747..975933400 100644 --- a/static/coffee/controllers.coffee +++ b/static/coffee/controllers.coffee @@ -16,6 +16,7 @@ CASE_SUMMARY_MAX_LEN = 255 CASE_NOTE_MAX_LEN = 1024 OUTGOING_TEXT_MAX_LEN = 480 +SINGLETON_ALERTS = ['pod_load_api_failure'] #============================================================================ # Inbox controller (DOM parent of messages and cases) @@ -508,7 +509,6 @@ controllers.controller('HomeController', ['$scope', '$controller', 'LabelService # Case view controller #============================================================================ controllers.controller('CaseController', ['$scope', '$window', '$timeout', 'CaseService', 'ContactService', 'MessageService', 'PartnerService', 'UtilsService', ($scope, $window, $timeout, CaseService, ContactService, MessageService, PartnerService, UtilsService) -> - $scope.allLabels = $window.contextData.all_labels $scope.fields = $window.contextData.fields @@ -517,12 +517,27 @@ controllers.controller('CaseController', ['$scope', '$window', '$timeout', 'Case $scope.newMessage = '' $scope.sending = false + $scope.alerts = [] + $scope.init = (caseId, maxMsgChars) -> $scope.caseId = caseId $scope.msgCharsRemaining = $scope.maxMsgChars = maxMsgChars $scope.refresh() + $scope.$on('alert', (e, alert) -> + $scope.addAlert(alert)) + + $scope.$on('timelineChanged', (e) -> + $scope.$broadcast('timelineChanged') if e.targetScope != $scope) + + $scope.addAlert = (alert) -> + $scope.alerts.push(alert) if (not shouldIgnoreAlert(alert)) + + shouldIgnoreAlert = ({type}) -> + type in SINGLETON_ALERTS and + $scope.alerts.some((d) -> type == d.type) + $scope.refresh = () -> CaseService.fetchSingle($scope.caseId).then((caseObj) -> $scope.caseObj = caseObj @@ -845,12 +860,74 @@ controllers.controller('DateRangeController', ['$scope', ($scope) -> #============================================================================ # Pod controller #============================================================================ -controllers.controller('PodController', ['$scope', 'PodApiService', ($scope, PodApiService) -> +controllers.controller('PodController', ['$q', '$scope', 'PodApiService', 'PodUIService', ($q, $scope, PodApiService, PodUIService) -> + {PodApiServiceError} = PodApiService + $scope.init = (podId, caseId, podConfig) -> $scope.podId = podId $scope.caseId = caseId $scope.podConfig = podConfig + $scope.status = 'loading' + + $scope.update() + .then(-> $scope.status = 'idle') + .catch(utils.trap(PodApiServiceError, onLoadApiFailure, $q.reject)) - return PodApiService.get(podId, caseId) + $scope.update = -> + PodApiService.get($scope.podId, $scope.caseId) + .then(parsePodData) .then((d) -> $scope.podData = d) + + $scope.trigger = ({type, name, payload, confirm}) -> + $q.resolve() + .then(-> PodUIService.confirmAction(name) if confirm) + .then(-> $scope.podData.actions = updateAction(type, {isBusy: true})) + .then(-> PodApiService.trigger($scope.podId, $scope.caseId, type, payload)) + .then((res) -> onTriggerDone(type, res)) + .catch(utils.trap(PodApiServiceError, onTriggerApiFailure, $q.reject)) + .then(-> $scope.podData.actions = updateAction(type, {isBusy: false})) + + onTriggerDone = (type, {success, payload}) -> + if success + onTriggerSuccess() + else + onTriggerFailure(payload) + + onLoadApiFailure = -> + $scope.status = 'loading_failed' + $scope.$emit('alert', PodUIService.alertLoadApiFailure()) + + onTriggerApiFailure = -> + $scope.$emit('alert', PodUIService.alertActionApiFailure()) + + onTriggerFailure = ({message}) -> + $scope.$emit('alert', PodUIService.alertActionFailure(message)) + + onTriggerSuccess = () -> + $scope.$emit('timelineChanged') + $scope.update() + + updateAction = (type, props) -> + $scope.podData.actions + .map((d) -> if d.type == type then angular.extend({}, d, props) else d) + + parsePodData = (d) -> + d = angular.extend({ + items: [], + actions: [] + }, d) + + d.actions = d.actions + .map(parsePodAction) + + d + + parsePodAction = ({type, name, busy_text, confirm, payload}) -> { + type, + name, + payload, + confirm: confirm ? false, + busyText: busy_text ? name, + isBusy: false + } ]) diff --git a/static/coffee/directives.coffee b/static/coffee/directives.coffee index 6a20ca97e..2cd665113 100644 --- a/static/coffee/directives.coffee +++ b/static/coffee/directives.coffee @@ -53,6 +53,21 @@ directives.directive('cpFieldvalue', () -> } ) + +directives.directive('cpAlert', -> { + restrict: 'E', + transclude: true, + scope: {type: '@'}, + templateUrl: '/sitestatic/templates/alert.html' +}) + + +directives.directive('cpAlerts', -> { + templateUrl: '/sitestatic/templates/alerts.html', + scope: {alerts: '='} +}) + + #===================================================================== # Pod directive #===================================================================== diff --git a/static/coffee/services.coffee b/static/coffee/services.coffee index d3e2a9ae9..0685e2ac4 100644 --- a/static/coffee/services.coffee +++ b/static/coffee/services.coffee @@ -551,12 +551,95 @@ services.factory('UtilsService', ['$window', '$uibModal', ($window, $uibModal) - ]) +#===================================================================== +# Modals service +#===================================================================== +services.factory('ModalService', ['$rootScope', '$uibModal', ($rootScope, $uibModal) -> + new class ModalService + confirm: ({ + context = {}, + title = null, + prompt = null, + templateUrl = '/sitestatic/templates/modals/confirm.html' + } = {}) -> + $uibModal.open({ + templateUrl, + scope: angular.extend($rootScope.$new(true), { + title, + prompt, + context + }), + controller: ($scope, $uibModalInstance) -> + $scope.ok = -> $uibModalInstance.close() + $scope.cancel = -> $uibModalInstance.dismiss() + }) + .result +]) + + +#===================================================================== +# PodUIService service +#===================================================================== +services.factory('PodUIService', ['ModalService', (ModalService) -> + new class PodUIService + confirmAction: (name) -> + ModalService.confirm({ + templateUrl: '/sitestatic/templates/modals/pod-action-confirm.html', + context: {name} + }) + + alertActionFailure: (message) -> { + templateUrl: '/sitestatic/templates/alerts/pod-action-failure.html', + context: {message} + } + + alertActionApiFailure: () -> { + templateUrl: '/sitestatic/templates/alerts/pod-action-api-failure.html' + } + + alertLoadApiFailure: () -> { + templateUrl: '/sitestatic/templates/alerts/pod-load-api-failure.html' + } +]) + + #===================================================================== # Pod API service #===================================================================== -services.factory('PodApiService', ['$window', '$http', ($window, $http) -> - new class PodApiService - get: (podId, caseId) -> - $http.get("/pods/read/#{podId}/", {params: {case_id: caseId}}) +services.factory('PodApiService', ['$q', '$window', '$http', ($q, $window, $http) -> + class PodApiServiceError extends Error + constructor: (error) -> + this.error = error + + method = (fn) -> + res = (args...) -> + $http(fn(args...)) + .catch((e) -> $q.reject(new PodApiServiceError(e))) .then((d) -> d.data) + + res.fn = fn + res + + new class PodApiService + PodApiServiceError: PodApiServiceError, + + method: method, + + get: method((podId, caseId) -> { + method: 'GET', + url: "/pods/read/#{podId}/", + params: {case_id: caseId} + }) + + trigger: method((podId, caseId, type, payload = {}) -> { + method: 'POST', + url: "/pods/action/#{podId}/", + data: { + case_id: caseId + action: { + type, + payload + } + } + }) ]) diff --git a/static/coffee/utils.coffee b/static/coffee/utils.coffee index 113988e69..5c063a1d8 100644 --- a/static/coffee/utils.coffee +++ b/static/coffee/utils.coffee @@ -44,4 +44,10 @@ namespace('utils', (exports) -> if angular.equals(item[prop], value) return item return null -) \ No newline at end of file + + exports.trap = (type, acceptFn, rejectFn) -> (v) -> + if v instanceof type + acceptFn(v) + else + rejectFn(v) +) diff --git a/static/less/pods.less b/static/less/pods.less new file mode 100644 index 000000000..748965f1b --- /dev/null +++ b/static/less/pods.less @@ -0,0 +1,5 @@ +.panel-body-pod { + // pod body already has padding defined by the grid it uses for its items + padding-left: 0; + padding-right: 0; +} diff --git a/static/templates/alert.html b/static/templates/alert.html new file mode 100644 index 000000000..9e3634e91 --- /dev/null +++ b/static/templates/alert.html @@ -0,0 +1,6 @@ +
+ + +
diff --git a/static/templates/alerts.html b/static/templates/alerts.html new file mode 100644 index 000000000..9e75889ca --- /dev/null +++ b/static/templates/alerts.html @@ -0,0 +1,9 @@ +
+
+ [[ alert.message ]] +
+ +
+
+
+
diff --git a/static/templates/alerts/pod-action-api-failure.html b/static/templates/alerts/pod-action-api-failure.html new file mode 100644 index 000000000..6f568104c --- /dev/null +++ b/static/templates/alerts/pod-action-api-failure.html @@ -0,0 +1,3 @@ + + Sorry, we encountered a problem on our side while completing your action. + diff --git a/static/templates/alerts/pod-action-failure.html b/static/templates/alerts/pod-action-failure.html new file mode 100644 index 000000000..572f88408 --- /dev/null +++ b/static/templates/alerts/pod-action-failure.html @@ -0,0 +1,3 @@ + + [[ alert.context.message ]] + diff --git a/static/templates/alerts/pod-load-api-failure.html b/static/templates/alerts/pod-load-api-failure.html new file mode 100644 index 000000000..fef164b05 --- /dev/null +++ b/static/templates/alerts/pod-load-api-failure.html @@ -0,0 +1,3 @@ + + Sorry, a problem on our side prevented some of the information on this page from loading. + diff --git a/static/templates/case-confirm-modals.html b/static/templates/case-confirm-modals.html new file mode 100644 index 000000000..0cbe64a22 --- /dev/null +++ b/static/templates/case-confirm-modals.html @@ -0,0 +1,16 @@ +
+
+ + + + + +
+
diff --git a/static/templates/modals/confirm.html b/static/templates/modals/confirm.html new file mode 100644 index 000000000..18cc2f10f --- /dev/null +++ b/static/templates/modals/confirm.html @@ -0,0 +1,14 @@ +
+ + + + + +
diff --git a/static/templates/modals/pod-action-confirm.html b/static/templates/modals/pod-action-confirm.html new file mode 100644 index 000000000..122d5d222 --- /dev/null +++ b/static/templates/modals/pod-action-confirm.html @@ -0,0 +1,12 @@ + + + + + diff --git a/static/templates/pod.html b/static/templates/pod.html index ebba2568c..8f61c426f 100644 --- a/static/templates/pod.html +++ b/static/templates/pod.html @@ -1,12 +1,43 @@
[[ podConfig.title ]]
-
+
+ Loading... +
+ +
+ Could not load +
+ +
-
-
[[ item.name ]]
-
[[ item.value ]]
+
+
+
+
+
[[ item.name ]]
+
+ +
+
[[ item.value ]]
+
+
+
+ +
diff --git a/templates/cases/case_read.haml b/templates/cases/case_read.haml index d6a57733d..f794ff193 100644 --- a/templates/cases/case_read.haml +++ b/templates/cases/case_read.haml @@ -66,6 +66,8 @@ .row .col-md-8 + + %form.sendform{ ng-if:"!caseObj.is_closed" } // TODO maxlength not working %textarea.form-control{ type:"text", autocomplete:"off", placeholder:"Enter message", ng-maxlength:"{{ maxMsgChars }}", ng-model:"$parent.newMessage", ng-disabled:"sending", ng-change:"onNewMessageChanged()", ng-trim:"false" }