diff --git a/package.json b/package.json index a2052729..78d922c1 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ ], "coverageThreshold": { "global": { - "statements": 43, + "statements": 44, "branches": 31, "functions": 48, "lines": 43 diff --git a/src/js/controllers/__snapshots__/ctrl-widget-catalog.test.js.snap b/src/js/controllers/__snapshots__/ctrl-widget-catalog.test.js.snap new file mode 100644 index 00000000..6d61f89d --- /dev/null +++ b/src/js/controllers/__snapshots__/ctrl-widget-catalog.test.js.snap @@ -0,0 +1,188 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`widgetCatalogController defines expected scope vars 1`] = ` +Array [ + "$watch", + "$on", + "search", + "totalWidgets", + "count", + "noWidgetsInstalled", + "isShowingFilters", + "ready", + "activeFilters", + "showFilters", + "clearFilters", + "clearFiltersAndSearch", + "featuredWidgets", + "toggleFilter", + "widgets", + "filters", + "isFiltered", + "jestTest", +] +`; + +exports[`widgetCatalogController implements url based filters and search on initial load 1`] = ` +Array [ + "$watch", + "$on", + "search", + "totalWidgets", + "count", + "noWidgetsInstalled", + "isShowingFilters", + "ready", + "activeFilters", + "showFilters", + "clearFilters", + "clearFiltersAndSearch", + "featuredWidgets", + "toggleFilter", + "widgets", + "filters", + "isFiltered", + "jestTest", +] +`; + +exports[`widgetCatalogController implements url based filters and search on initial load 2`] = ` +Object { + "SuPpOrTeD FoUr!!": Object { + "clean": "su_pp_or_te_d_fo_ur", + "isActive": false, + "text": "SuPpOrTeD FoUr!! Questions", + }, + "feature1": Object { + "clean": "feature1", + "isActive": true, + "text": "feature1", + }, + "feature2": Object { + "clean": "feature2", + "isActive": false, + "text": "feature2", + }, + "feature3": Object { + "clean": "feature3", + "isActive": false, + "text": "feature3", + }, + "supported1": Object { + "clean": "supported1", + "isActive": false, + "text": "supported1 Questions", + }, + "supported2": Object { + "clean": "supported2", + "isActive": false, + "text": "supported2 Questions", + }, + "supported_three": Object { + "clean": "supported_three", + "isActive": true, + "text": "supported_three Questions", + }, +} +`; + +exports[`widgetCatalogController initializes with no filters and search on initial load 1`] = ` +Array [ + "$watch", + "$on", + "search", + "totalWidgets", + "count", + "noWidgetsInstalled", + "isShowingFilters", + "ready", + "activeFilters", + "showFilters", + "clearFilters", + "clearFiltersAndSearch", + "featuredWidgets", + "toggleFilter", + "widgets", + "filters", + "isFiltered", + "jestTest", +] +`; + +exports[`widgetCatalogController initializes with no filters and search on initial load 2`] = ` +Object { + "SuPpOrTeD FoUr!!": Object { + "clean": "su_pp_or_te_d_fo_ur", + "isActive": false, + "text": "SuPpOrTeD FoUr!! Questions", + }, + "feature1": Object { + "clean": "feature1", + "isActive": false, + "text": "feature1", + }, + "feature2": Object { + "clean": "feature2", + "isActive": false, + "text": "feature2", + }, + "feature3": Object { + "clean": "feature3", + "isActive": false, + "text": "feature3", + }, + "supported1": Object { + "clean": "supported1", + "isActive": false, + "text": "supported1 Questions", + }, + "supported2": Object { + "clean": "supported2", + "isActive": false, + "text": "supported2 Questions", + }, + "supported_three": Object { + "clean": "supported_three", + "isActive": false, + "text": "supported_three Questions", + }, +} +`; + +exports[`widgetCatalogController properly generates clean filter names 1`] = ` +Array [ + "feature1", + "feature3", + "supported1", + "supported_three", + "su_pp_or_te_d_fo_ur", + "feature2", + "supported2", +] +`; + +exports[`widgetCatalogController toggling on a filter updates scope 1`] = ` +Array [ + Object { + "dir": "mockDir1", + "icon": "widget.jpg", + "id": 1, + "in_catalog": "1", + "meta_data": Object { + "about": "information about the widget", + "demo": "1", + "excerpt": "more information about the widget", + "features": Array [ + "feature1", + "feature3", + ], + "supported_data": Array [ + "supported1", + "supported_three", + "SuPpOrTeD FoUr!!", + ], + }, + "name": "widget1", + }, +] +`; diff --git a/src/js/controllers/ctrl-widget-catalog.js b/src/js/controllers/ctrl-widget-catalog.js index 475c5109..5e87b1ed 100644 --- a/src/js/controllers/ctrl-widget-catalog.js +++ b/src/js/controllers/ctrl-widget-catalog.js @@ -18,11 +18,16 @@ if (window.location.href.match(/\/widgets($|\/\D|\?)/g)) { } app.controller('widgetCatalogCtrl', function(Please, $scope, $window, $location, widgetSrv) { - const filterList = {} + const filterList = {} // key is raw filter name, value is filter object const mapCleanToFilter = {} // key is clean name, value is filter object const displayedWidgets = [] let allWidgets = [] + const resetFilters = () => { + for (i in filterList) delete filterList[i] + for (i in mapCleanToFilter) delete mapCleanToFilter[i] + } + const showFilters = () => { $scope.isShowingFilters = true } @@ -68,12 +73,17 @@ app.controller('widgetCatalogCtrl', function(Please, $scope, $window, $location, const _getFiltersFromWidgets = widgets => { widgets.forEach(widget => { - widget.meta_data.features.forEach(feature => { - if (!filterList.hasOwnProperty(feature)) _registerFilter(feature) - }) - widget.meta_data.supported_data.forEach(data => { - if (!filterList.hasOwnProperty(data)) _registerFilter(data, ' Questions') - }) + if (widget.meta_data.hasOwnProperty('features')) { + widget.meta_data.features.forEach(feature => { + if (!filterList.hasOwnProperty(feature)) _registerFilter(feature) + }) + } + + if (widget.meta_data.hasOwnProperty('supported_data')) { + widget.meta_data.supported_data.forEach(data => { + if (!filterList.hasOwnProperty(data)) _registerFilter(data, ' Questions') + }) + } }) } @@ -84,10 +94,15 @@ app.controller('widgetCatalogCtrl', function(Please, $scope, $window, $location, // check for filter matches for (let filterName in filterList) { - const isActive = filterList[filterName].isActive - - if (isActive && !wFeatures.includes(filterName) && !wSupport.includes(filterName)) { - return false + // this filter is active + if (filterList[filterName].isActive) { + // widget has features/support and the feature isn't in either + if ( + (!wFeatures || !wFeatures.includes(filterName)) && + (!wSupport || !wSupport.includes(filterName)) + ) { + return false + } } } @@ -125,18 +140,19 @@ app.controller('widgetCatalogCtrl', function(Please, $scope, $window, $location, const _loadWidgets = () => { // load list of widgets - widgetSrv.getWidgetsByType('all').then(widgets => { - if (!widgets || !widgets.length || !widgets.length > 0) { - $scope.noWidgetsInstalled = true - Please.$apply() - return + widgetSrv.getWidgetsByType('all').then(loaded => { + if (!loaded || !loaded.length || !loaded.length > 0) { + resetFilters() + loaded = [] } - $scope.noWidgetsInstalled = false - allWidgets = widgets - _getFiltersFromWidgets(widgets) + allWidgets = loaded + + $scope.noWidgetsInstalled = allWidgets.length == 0 + + _getFiltersFromWidgets(allWidgets) // memoize icon paths - widgets.forEach(widget => { + allWidgets.forEach(widget => { widget.icon = Materia.Image.iconUrl(widget.dir, 275) }) @@ -157,6 +173,7 @@ app.controller('widgetCatalogCtrl', function(Please, $scope, $window, $location, } const _getFiltersFromURL = () => { + $scope.isShowingFilters = false for (let key in $location.search()) { if (key == 'search') { $scope.search = $location.search().search @@ -169,6 +186,7 @@ app.controller('widgetCatalogCtrl', function(Please, $scope, $window, $location, $scope.search = '' $scope.totalWidgets = -1 + $scope.count = -1 $scope.noWidgetsInstalled = false $scope.isShowingFilters = false $scope.ready = false @@ -176,6 +194,7 @@ app.controller('widgetCatalogCtrl', function(Please, $scope, $window, $location, $scope.showFilters = showFilters $scope.clearFilters = clearFilters $scope.clearFiltersAndSearch = clearFiltersAndSearch + $scope.featuredWidgets = [] $scope.toggleFilter = toggleFilter $scope.widgets = [] $scope.filters = filterList diff --git a/src/js/controllers/ctrl-widget-catalog.test.js b/src/js/controllers/ctrl-widget-catalog.test.js index f154e5be..3a45cb0c 100644 --- a/src/js/controllers/ctrl-widget-catalog.test.js +++ b/src/js/controllers/ctrl-widget-catalog.test.js @@ -1,21 +1,18 @@ describe('widgetCatalogController', () => { - var $controller - var mockPlease - var $q - var $rootScope - var $scope - var $window - var $location - var $timeout - var location - var sendMock - var iconUrlMock - var screenshotUrlMock - var screenshotThumbMock - + let $controller + let $q + let $scope + let $location + let location + let mockIconUrl + let mockWidgetSrv + let mockLocationSearch + let widgetsToReturn + + const mockUrl = 'http://localhost/widgets?customizable&search=widget&fake_feature' const widget1 = { id: 1, - icon: 'http://localhost/1.png', + dir: 'mockDir1', in_catalog: '1', meta_data: { about: 'information about the widget', @@ -28,7 +25,7 @@ describe('widgetCatalogController', () => { } const widget2 = { id: 2, - icon: 'http://localhost/2.png', + dir: 'mockDir2', in_catalog: '0', meta_data: { about: 'information about the widget', @@ -41,7 +38,7 @@ describe('widgetCatalogController', () => { } const widget3 = { id: 3, - icon: 'http://localhost/3.png', + dir: 'mockDir3', in_catalog: '0', meta_data: { about: 'information about the widget', @@ -53,10 +50,40 @@ describe('widgetCatalogController', () => { name: 'widget3' } + const widgetWithoutFeatures = { + id: 4, + dir: 'mockDir4', + in_catalog: '0', + meta_data: { + about: 'information about the widget', + demo: '4', + excerpt: 'more information about the widget' + // features and supported_data are null!! + }, + name: 'widgetWithoutFeatures' + } + beforeEach(() => { - mockPlease = { $apply: jest.fn() } + widgetsToReturn = [widget1, widget2, widget3, widgetWithoutFeatures] + // mocks getWidgetsByType promise with a synchronous implementation + // why? because it's an internal private method and getting a handle + // to wait for it to finish is difficult + let getWidgetsByTypeImmediately = jest.fn().mockReturnValue({ + then: cb => { + cb(widgetsToReturn) + } + }) + // mock all the required services + mockWidgetSrv = { getWidgetsByType: getWidgetsByTypeImmediately } let app = angular.module('materia') - app.factory('Please', () => mockPlease) + app.factory('Please', () => ({ $apply: jest.fn() })) + app.factory('selectedWidgetSrv', () => ({})) + app.factory('dateTimeServ', () => ({})) + app.factory('widgetSrv', () => mockWidgetSrv) + + // mock Materia.Image.iconUrl + mockIconUrl = jest.fn().mockReturnValue('widget.jpg') + window.Materia = { Image: { iconUrl: mockIconUrl } } // mock window.location let mockWindow = {} @@ -69,203 +96,205 @@ describe('widgetCatalogController', () => { app.factory('$window', () => mockWindow) // manually set the url - const mockUrl = 'http://localhost/widgets?customizable&search=widget&fake_feature' window.history.pushState({}, '', mockUrl) - require('../materia-namespace') - require('../materia-constants') - require('../materia/materia.coms.json') - require('../services/srv-selectedwidget') - require('../services/srv-datetime') - require('../services/srv-widget') - require('angular-animate') - require('./ctrl-widget-catalog') - - Namespace('Materia.Coms.Json').send = sendMock = jest.fn(() => { - const deferred = $q.defer() - deferred.resolve([widget1, widget2, widget3]) - return deferred.promise - }) - - Namespace('Materia.Image').iconUrl = iconUrlMock = jest.fn(() => { - const deferred = $q.defer() - deferred.resolve('widget.jpg') - return deferred.promise - }) + // build a mock $scope + $scope = { + $watch: jest.fn(), + $on: jest.fn() + } - Namespace('Materia.Image').screenshotUrl = screenshotUrlMock = jest.fn(() => { - const deferred = $q.defer() - deferred.resolve('screenshot-full.jpg') - return deferred.promise - }) + mockLocationSearch = { search: '' } - Namespace('Materia.Image').screenshotThumbUrl = screenshotThumbMock = jest.fn(() => { - const deferred = $q.defer() - deferred.resolve('screenshot-thumb.jpg') - return deferred.promise - }) + require('angular-animate') + require('./ctrl-widget-catalog') - inject((_$controller_, _$window_, _$timeout_, _$location_, _$q_, _$rootScope_) => { + inject((_$controller_, _$location_, _$q_) => { $controller = _$controller_ - $window = _$window_ - $timeout = _$timeout_ $location = _$location_ $q = _$q_ - $rootScope = _$rootScope_ + + // mock to get/set url params + $location.search = jest.fn((key, val) => { + if (!key) { + return mockLocationSearch + } + return { + replace: jest.fn() + } + }) }) + }) - $scope = { - $watch: jest.fn(), - $on: jest.fn() - } + it('defines expected scope vars', () => { + $controller('widgetCatalogCtrl', { $scope }) + expect(Object.keys($scope)).toMatchSnapshot() + }) - // mock to get/set url params - $location.search = jest.fn((key, val) => { - if (!key) { - return { feature1: true, search: 'widget', fake_feature: true } - } - return { - replace: jest.fn() - } - }) + it('loads widgets from the widget service', () => { + $controller('widgetCatalogCtrl', { $scope }) + expect(mockWidgetSrv.getWidgetsByType).toHaveBeenCalledTimes(1) + expect(mockWidgetSrv.getWidgetsByType).toHaveBeenCalledWith('all') + }) - var controller = $controller('widgetCatalogCtrl', { $scope }) - $timeout.flush() + it('uses Materia.Image.iconUrl to get each widget icon', () => { + $controller('widgetCatalogCtrl', { $scope }) + expect(mockIconUrl).toHaveBeenCalledTimes(4) + expect(mockIconUrl).toHaveBeenCalledWith('mockDir1', 275) + expect(mockIconUrl).toHaveBeenCalledWith('mockDir2', 275) + expect(mockIconUrl).toHaveBeenCalledWith('mockDir3', 275) + expect(mockIconUrl).toHaveBeenCalledWith('mockDir4', 275) }) - it('defines expected scope vars', () => { - expect($scope.search).toBeDefined() - expect($scope.totalWidgets).toBeDefined() - expect($scope.isShowingFilters).toBeDefined() - expect($scope.ready).toBeDefined() - expect($scope.activeFilters).toBeDefined() - expect($scope.showFilters).toBeDefined() - expect($scope.clearFilters).toBeDefined() - expect($scope.clearFiltersAndSearch).toBeDefined() - expect($scope.toggleFilter).toBeDefined() - expect($scope.widgets).toBeDefined() - expect($scope.filters).toBeDefined() - expect($scope.isFiltered).toBeDefined() + it('handles no widgets', () => { + widgetsToReturn = [] + $controller('widgetCatalogCtrl', { $scope }) + + expect($scope.widgets).toHaveLength(0) + expect($scope.featuredWidgets).toHaveLength(0) + expect($scope.count).toEqual(0) + expect($scope.noWidgetsInstalled).toBe(true) + expect($scope.isFiltered).toBe(false) + expect($scope.activeFilters).toHaveLength(0) + }) + + it('initializes with no filters and search on initial load', () => { + $controller('widgetCatalogCtrl', { $scope }) + + expect(Object.keys($scope)).toMatchSnapshot() + expect($scope.search).toBe('') + expect($scope.activeFilters).toEqual([]) + expect($scope.isShowingFilters).toBe(false) + expect($scope.isFiltered).toBe(false) + + expect($scope.count).toBe(4) + + expect($scope.featuredWidgets).toHaveLength(1) + expect($scope.featuredWidgets).toContain(widget1) + + expect($scope.widgets).toHaveLength(3) + expect($scope.widgets).toContain(widget2) + expect($scope.widgets).toContain(widget3) + expect($scope.widgets).toContain(widgetWithoutFeatures) + + expect($scope.filters).toMatchSnapshot() }) - it('grabs widgets and sets defaults properly', () => { - // the widgets were requested - expect(sendMock).toHaveBeenCalledTimes(1) + it('implements url based filters and search on initial load', () => { + mockLocationSearch = { + feature1: true, + search: 'widget', + invalid_feature: true, + supported_three: true + } + $controller('widgetCatalogCtrl', { $scope }) - // initial state set based on url params + expect(Object.keys($scope)).toMatchSnapshot() expect($scope.search).toBe('widget') - expect($scope.ready).toBe(true) + expect($scope.activeFilters).toEqual(['feature1', 'supported_three']) expect($scope.isShowingFilters).toBe(true) - expect($scope.activeFilters).toEqual(['feature1']) expect($scope.isFiltered).toBe(true) - // widgets sent back from the API are mocked above - just do a quick check to make sure we have what we need - expect($scope.totalWidgets).toBe(3) + expect($scope.count).toBe(1) + + // note: featuredWidgets is not altered by filters or search + expect($scope.featuredWidgets).toHaveLength(1) expect($scope.featuredWidgets).toContain(widget1) - expect($scope.featuredWidgets.length).toBe(1) + + // only one widget matches the search and filter options + expect($scope.widgets).toHaveLength(1) expect($scope.widgets).toContain(widget1) - expect($scope.widgets.length).toBe(1) - - //available filters should be constructed from widget metadata - const numFilters = Object.keys($scope.filters).length - expect(numFilters).toBe(7) - expect($scope.filters).toHaveProperty('feature1') - expect($scope.filters).toHaveProperty('feature2') - expect($scope.filters).toHaveProperty('feature3') - expect($scope.filters).toHaveProperty('supported1') - expect($scope.filters).toHaveProperty('supported2') + + expect($scope.filters).toMatchSnapshot() }) it('properly generates clean filter names', () => { + $controller('widgetCatalogCtrl', { $scope }) const mapCleanToFilter = $scope.jestTest.getLocalVar('mapCleanToFilter') - const cleanFilters = Object.keys(mapCleanToFilter) - expect(cleanFilters.length).toBe(7) - expect(cleanFilters).toContain('feature1') - expect(cleanFilters).toContain('feature3') - expect(cleanFilters).toContain('supported1') - expect(cleanFilters).toContain('supported_three') - expect(cleanFilters).toContain('su_pp_or_te_d_fo_ur') - expect(cleanFilters).toContain('feature2') - expect(cleanFilters).toContain('supported2') + expect(Object.keys(mapCleanToFilter)).toMatchSnapshot() }) - it('filters out widgets correctly', () => { - // expect all the filters to be false except 'feature1' - for (let filterName in $scope.filters) { - expect($scope.filters[filterName].isActive).toBe(filterName == 'feature1') - } - expect($scope.isFiltered).toBe(true) - expect($scope.widgets.length).toBe(1) - expect($scope.widgets[0].name).toBe('widget1') - - // turn off that filter - $scope.toggleFilter('feature1') - - // now all the filters are off (except search='widget') - for (let filterName in $scope.filters) { - expect($scope.filters[filterName].isActive).toBe(false) - } - - // even though there is a search query set, it doesn't filter anything out - // so isFiltered stays false - expect($scope.search).toBe('widget') + it('toggling on a filter updates scope', () => { + $controller('widgetCatalogCtrl', { $scope }) expect($scope.isFiltered).toBe(false) + expect($scope.activeFilters).toHaveLength(0) + expect($scope.filters['feature1'].isActive).toBe(false) + expect($scope.widgets).toHaveLength(3) + expect($scope.count).toBe(4) - expect($scope.featuredWidgets.length).toBe(1) - expect($scope.featuredWidgets[0].name).toBe('widget1') - expect($scope.widgets.length).toBe(2) - expect($scope.widgets[0].name).toBe('widget2') - expect($scope.widgets[1].name).toBe('widget3') + $scope.toggleFilter('feature1') // toggle on - // toggle again - $scope.toggleFilter('feature1') - for (let filterName in $scope.filters) { - expect($scope.filters[filterName].isActive).toBe(filterName == 'feature1') - } expect($scope.isFiltered).toBe(true) - expect($scope.widgets.length).toBe(1) - expect($scope.widgets[0].name).toBe('widget1') + expect($scope.activeFilters).toHaveLength(1) + expect($scope.filters['feature1'].isActive).toBe(true) + expect($scope.widgets).toHaveLength(1) + expect($scope.widgets).toMatchSnapshot() + expect($scope.count).toBe(1) }) it('will filter widgets based on a search query', () => { - // disable the filter to test search - expect($scope.filters['feature1'].isActive).toBe(true) - $scope.toggleFilter('feature1') - expect($scope.filters['feature1'].isActive).toBe(false) - - // set the search query - $scope.search = '1' + $controller('widgetCatalogCtrl', { $scope }) const _onSearch = $scope.jestTest.getLocalVar('_onSearch') - _onSearch() - expect($scope.widgets.length).toBe(1) - expect($scope.widgets[0].name).toBe('widget1') - $scope.search = '123' + expect($scope.count).toBe(4) + + // search for widget 4 + $scope.search = 'widgetWithoutFeatures' _onSearch() - expect($scope.widgets.length).toBe(0) - $scope.search = '' + expect($scope.count).toBe(1) + expect($scope.widgets).toHaveLength(1) + expect($scope.widgets).toContain(widgetWithoutFeatures) + + // search for widget 3 + $scope.search = 'widget3' _onSearch() - expect($scope.widgets.length).toBe(2) + + expect($scope.count).toBe(1) + expect($scope.widgets).toHaveLength(1) + expect($scope.widgets).toContain(widget3) }) it('can toggle whether the filters are showing', () => { + $controller('widgetCatalogCtrl', { $scope }) + $scope.showFilters() expect($scope.isShowingFilters).toBe(true) $scope.clearFilters() expect($scope.isShowingFilters).toBe(false) - $scope.showFilters() - expect($scope.isShowingFilters).toBe(true) }) - it('can clear the filters', () => { - expect($scope.filters['feature1'].isActive).toBe(true) - expect($scope.search).toBe('widget') + it('can clear filters and search', () => { + $controller('widgetCatalogCtrl', { $scope }) + const _onSearch = $scope.jestTest.getLocalVar('_onSearch') + $scope.search = 'widget1' + _onSearch() // enable search + $scope.toggleFilter('feature1') // toggle on + expect($scope.activeFilters).toHaveLength(1) + expect($scope.search).toBe('widget1') + expect($scope.count).toBe(1) + expect($scope.widgets).toHaveLength(1) + + // clear everything $scope.clearFiltersAndSearch() - // expect all the filters to be false - for (let filterName in $scope.filters) { - expect($scope.filters[filterName].isActive).toBe(false) - } + expect($scope.activeFilters).toHaveLength(0) expect($scope.search).toBe('') + expect($scope.count).toBe(4) + expect($scope.widgets).toHaveLength(3) + }) + + it('handles no widgets having features', () => { + widgetsToReturn = [widgetWithoutFeatures] + $controller('widgetCatalogCtrl', { $scope }) + // $scope.toggleFilter('feature1') // toggle on + expect($scope.count).toEqual(1) + }) + + it('omits widgets with null features when a feature filter is enabled', () => { + widgetsToReturn = [widget1, widgetWithoutFeatures] + $controller('widgetCatalogCtrl', { $scope }) + expect($scope.count).toEqual(2) + $scope.toggleFilter('feature1') // toggle on + expect($scope.count).toEqual(1) }) })