From c9ee2dbf84328a61371c5110ffd7d232378df800 Mon Sep 17 00:00:00 2001 From: Juanfran Date: Mon, 27 Apr 2015 10:09:27 +0200 Subject: [PATCH] tg-repeat --- app/coffee/app.coffee | 3 +- app/coffee/utils.coffee | 15 +- app/js/tg-repeat.js | 310 ++++++++++++++++++ .../dropdown-project-list.directive.coffee | 4 +- .../dropdown-project-list.jade | 2 +- .../navigation-bar.directive.coffee | 3 +- .../navigation-bar/navigation-bar.jade | 2 +- .../projects/listing/listing.directive.coffee | 5 +- app/modules/projects/listing/listing.jade | 6 +- app/modules/projects/projects.service.coffee | 8 +- bower.json | 3 +- gulpfile.js | 2 + 12 files changed, 344 insertions(+), 19 deletions(-) create mode 100644 app/js/tg-repeat.js diff --git a/app/coffee/app.coffee b/app/coffee/app.coffee index 3c047ae7..2c5275f9 100644 --- a/app/coffee/app.coffee +++ b/app/coffee/app.coffee @@ -350,7 +350,8 @@ modules = [ "ngRoute", "ngAnimate", "pascalprecht.translate", - "infinite-scroll" + "infinite-scroll", + "tgRepeat" ].concat(_.map(@.taigaContribPlugins, (plugin) -> plugin.module)) # Main module definition diff --git a/app/coffee/utils.coffee b/app/coffee/utils.coffee index 4205b612..58874d4f 100644 --- a/app/coffee/utils.coffee +++ b/app/coffee/utils.coffee @@ -158,13 +158,18 @@ replaceTags = (str, tags, replace) -> return str -defineImmutableProperty = (obj, name, variable) => +defineImmutableProperty = (obj, name, fn) => Object.defineProperty obj, name, { get: () => - if _.isFunction(variable) - return variable.call(obj) - else - return variable + if !_.isFunction(fn) + throw "defineImmutableProperty third param must be a function" + + fn_result = fn() + if fn_result && _.isObject(fn_result) + if fn_result.size == undefined + throw "defineImmutableProperty must return immutable data" + + return fn_result } taiga = @.taiga diff --git a/app/js/tg-repeat.js b/app/js/tg-repeat.js new file mode 100644 index 00000000..8b6528a8 --- /dev/null +++ b/app/js/tg-repeat.js @@ -0,0 +1,310 @@ +/* + --replace + minError -> angular.$$minErr + isString -> angular.isString + isArray -> angular.isArray + var expression = $attr.ngRepeat; -> var expression = $attr.tgRepeat; + forEach(nextBlockOrder, function(block) { -> nextBlockOrder.forEach(function(block) { + $scope.$watchCollection(rhs, function ngRepeatAction(collection) { + -> + $scope.$watch(rhs, function ngRepeatAction(immutable_collection) { + var collection = [] + + if (immutable_collection.toJS) { + collection = immutable_collection.toJS(); + } + --copy from angular + copy angular hashKey + copy angular createMap + copy angular isArrayLike + copy angular isWindow + copy angular NODE_TYPE_ELEMENT + copy angular nextUid + copy angular getBlockNodes + --add + jqLite = $ + var uid = 0; +*/ + +(function() { + var NODE_TYPE_ELEMENT = 1; + var uid = 0; + + function nextUid() { + return ++uid; + } + + function hashKey(obj, nextUidFn) { + var key = obj && obj.$$hashKey; + + if (key) { + if (typeof key === 'function') { + key = obj.$$hashKey(); + } + return key; + } + + var objType = typeof obj; + if (objType == 'function' || (objType == 'object' && obj !== null)) { + key = obj.$$hashKey = objType + ':' + (nextUidFn || nextUid)(); + } else { + key = objType + ':' + obj; + } + + return key; + } + + function createMap() { + return Object.create(null); + } + + function isArrayLike(obj) { + if (obj == null || isWindow(obj)) { + return false; + } + + var length = obj.length; + + if (obj.nodeType === NODE_TYPE_ELEMENT && length) { + return true; + } + + return angular.isString(obj) || angular.isArray(obj) || length === 0 || + typeof length === 'number' && length > 0 && (length - 1) in obj; + } + + function isWindow(obj) { + return obj && obj.window === obj; + } + + function isString(value) {return typeof value === 'string';} + + function getBlockNodes(nodes) { + // TODO(perf): just check if all items in `nodes` are siblings and if they are return the original + // collection, otherwise update the original collection. + var node = nodes[0]; + var endNode = nodes[nodes.length - 1]; + var blockNodes = [node]; + + do { + node = node.nextSibling; + if (!node) break; + blockNodes.push(node); + } while (node !== endNode); + + return jqLite(blockNodes); + } + + var isArray = Array.isArray; + + var jqLite = $; + + var ngRepeatDirective = ['$parse', '$animate', function($parse, $animate) { + var NG_REMOVED = '$$NG_REMOVED'; + var ngRepeatMinErr = angular.$$minErr('ngRepeat'); + var updateScope = function(scope, index, valueIdentifier, value, keyIdentifier, key, arrayLength) { + // TODO(perf): generate setters to shave off ~40ms or 1-1.5% + scope[valueIdentifier] = value; + if (keyIdentifier) scope[keyIdentifier] = key; + scope.$index = index; + scope.$first = (index === 0); + scope.$last = (index === (arrayLength - 1)); + scope.$middle = !(scope.$first || scope.$last); + // jshint bitwise: false + scope.$odd = !(scope.$even = (index&1) === 0); + // jshint bitwise: true + }; + var getBlockStart = function(block) { + return block.clone[0]; + }; + var getBlockEnd = function(block) { + return block.clone[block.clone.length - 1]; + }; + return { + restrict: 'A', + multiElement: true, + transclude: 'element', + priority: 1000, + terminal: true, + $$tlb: true, + compile: function ngRepeatCompile($element, $attr) { + var expression = $attr.tgRepeat; + var ngRepeatEndComment = document.createComment(' end ngRepeat: ' + expression + ' '); + var match = expression.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+track\s+by\s+([\s\S]+?))?\s*$/); + if (!match) { + throw ngRepeatMinErr('iexp', "Expected expression in form of '_item_ in _collection_[ track by _id_]' but got '{0}'.", + expression); + } + var lhs = match[1]; + var rhs = match[2]; + var aliasAs = match[3]; + var trackByExp = match[4]; + match = lhs.match(/^(?:(\s*[\$\w]+)|\(\s*([\$\w]+)\s*,\s*([\$\w]+)\s*\))$/); + if (!match) { + throw ngRepeatMinErr('iidexp', "'_item_' in '_item_ in _collection_' should be an identifier or '(_key_, _value_)' expression, but got '{0}'.", + lhs); + } + var valueIdentifier = match[3] || match[1]; + var keyIdentifier = match[2]; + if (aliasAs && (!/^[$a-zA-Z_][$a-zA-Z0-9_]*$/.test(aliasAs) || + /^(null|undefined|this|\$index|\$first|\$middle|\$last|\$even|\$odd|\$parent|\$root|\$id)$/.test(aliasAs))) { + throw ngRepeatMinErr('badident', "alias '{0}' is invalid --- must be a valid JS identifier which is not a reserved name.", + aliasAs); + } + var trackByExpGetter, trackByIdExpFn, trackByIdArrayFn, trackByIdObjFn; + var hashFnLocals = {$id: hashKey}; + if (trackByExp) { + trackByExpGetter = $parse(trackByExp); + } else { + trackByIdArrayFn = function(key, value) { + return hashKey(value); + }; + trackByIdObjFn = function(key) { + return key; + }; + } + return function ngRepeatLink($scope, $element, $attr, ctrl, $transclude) { + if (trackByExpGetter) { + trackByIdExpFn = function(key, value, index) { + // assign key, value, and $index to the locals so that they can be used in hash functions + if (keyIdentifier) hashFnLocals[keyIdentifier] = key; + hashFnLocals[valueIdentifier] = value; + hashFnLocals.$index = index; + return trackByExpGetter($scope, hashFnLocals); + }; + } + // Store a list of elements from previous run. This is a hash where key is the item from the + // iterator, and the value is objects with following properties. + // - scope: bound scope + // - element: previous element. + // - index: position + // + // We are using no-proto object so that we don't need to guard against inherited props via + // hasOwnProperty. + var lastBlockMap = createMap(); + $scope.$watch(rhs, function ngRepeatAction(immutable_collection) { + var collection = [] + + if (immutable_collection && immutable_collection.toJS) { + collection = immutable_collection.toJS(); + } + + var index, length, + previousNode = $element[0], // node that cloned nodes should be inserted after + // initialized to the comment node anchor + nextNode, + // Same as lastBlockMap but it has the current state. It will become the + // lastBlockMap on the next iteration. + nextBlockMap = createMap(), + collectionLength, + key, value, // key/value of iteration + trackById, + trackByIdFn, + collectionKeys, + block, // last object information {scope, element, id} + nextBlockOrder, + elementsToRemove; + if (aliasAs) { + $scope[aliasAs] = collection; + } + if (isArrayLike(collection)) { + collectionKeys = collection; + trackByIdFn = trackByIdExpFn || trackByIdArrayFn; + } else { + trackByIdFn = trackByIdExpFn || trackByIdObjFn; + // if object, extract keys, in enumeration order, unsorted + collectionKeys = []; + for (var itemKey in collection) { + if (collection.hasOwnProperty(itemKey) && itemKey.charAt(0) !== '$') { + collectionKeys.push(itemKey); + } + } + } + collectionLength = collectionKeys.length; + nextBlockOrder = new Array(collectionLength); + // locate existing items + for (index = 0; index < collectionLength; index++) { + key = (collection === collectionKeys) ? index : collectionKeys[index]; + value = collection[key]; + trackById = trackByIdFn(key, value, index); + if (lastBlockMap[trackById]) { + // found previously seen block + block = lastBlockMap[trackById]; + delete lastBlockMap[trackById]; + nextBlockMap[trackById] = block; + nextBlockOrder[index] = block; + } else if (nextBlockMap[trackById]) { + // if collision detected. restore lastBlockMap and throw an error + nextBlockOrder.forEach(function(block) { + if (block && block.scope) lastBlockMap[block.id] = block; + }); + throw ngRepeatMinErr('dupes', + "Duplicates in a repeater are not allowed. Use 'track by' expression to specify unique keys. Repeater: {0}, Duplicate key: {1}, Duplicate value: {2}", + expression, trackById, value); + } else { + // new never before seen block + nextBlockOrder[index] = {id: trackById, scope: undefined, clone: undefined}; + nextBlockMap[trackById] = true; + } + } + // remove leftover items + for (var blockKey in lastBlockMap) { + block = lastBlockMap[blockKey]; + elementsToRemove = getBlockNodes(block.clone); + $animate.leave(elementsToRemove); + if (elementsToRemove[0].parentNode) { + // if the element was not removed yet because of pending animation, mark it as deleted + // so that we can ignore it later + for (index = 0, length = elementsToRemove.length; index < length; index++) { + elementsToRemove[index][NG_REMOVED] = true; + } + } + block.scope.$destroy(); + } + // we are not using forEach for perf reasons (trying to avoid #call) + for (index = 0; index < collectionLength; index++) { + key = (collection === collectionKeys) ? index : collectionKeys[index]; + value = collection[key]; + block = nextBlockOrder[index]; + if (block.scope) { + // if we have already seen this object, then we need to reuse the + // associated scope/element + nextNode = previousNode; + // skip nodes that are already pending removal via leave animation + do { + nextNode = nextNode.nextSibling; + } while (nextNode && nextNode[NG_REMOVED]); + if (getBlockStart(block) != nextNode) { + // existing item which got moved + $animate.move(getBlockNodes(block.clone), null, jqLite(previousNode)); + } + previousNode = getBlockEnd(block); + updateScope(block.scope, index, valueIdentifier, value, keyIdentifier, key, collectionLength); + } else { + // new item which we don't know about + $transclude(function ngRepeatTransclude(clone, scope) { + block.scope = scope; + // http://jsperf.com/clone-vs-createcomment + var endNode = ngRepeatEndComment.cloneNode(false); + clone[clone.length++] = endNode; + // TODO(perf): support naked previousNode in `enter` to avoid creation of jqLite wrapper? + $animate.enter(clone, null, jqLite(previousNode)); + previousNode = endNode; + // Note: We only need the first/last node of the cloned nodes. + // However, we need to keep the reference to the jqlite wrapper as it might be changed later + // by a directive with templateUrl when its template arrives. + block.clone = clone; + nextBlockMap[block.id] = block; + updateScope(block.scope, index, valueIdentifier, value, keyIdentifier, key, collectionLength); + }); + } + } + lastBlockMap = nextBlockMap; + }); + }; + } + }; + }]; + + angular.module("tgRepeat", []).directive("tgRepeat", ngRepeatDirective); +})(); diff --git a/app/modules/navigation-bar/dropdown-project-list/dropdown-project-list.directive.coffee b/app/modules/navigation-bar/dropdown-project-list/dropdown-project-list.directive.coffee index 1d596710..cb5cceb9 100644 --- a/app/modules/navigation-bar/dropdown-project-list/dropdown-project-list.directive.coffee +++ b/app/modules/navigation-bar/dropdown-project-list/dropdown-project-list.directive.coffee @@ -1,7 +1,9 @@ DropdownProjectListDirective = (projectsService) -> link = (scope, el, attrs, ctrl) -> scope.vm = {} - taiga.defineImmutableProperty(scope.vm, "projects", projectsService.projects) + + taiga.defineImmutableProperty(scope.vm, "projects", () -> projectsService.projects.get("recents")) + scope.vm.newProject = -> projectsService.newProject() diff --git a/app/modules/navigation-bar/dropdown-project-list/dropdown-project-list.jade b/app/modules/navigation-bar/dropdown-project-list/dropdown-project-list.jade index d9548202..08f376b3 100644 --- a/app/modules/navigation-bar/dropdown-project-list/dropdown-project-list.jade +++ b/app/modules/navigation-bar/dropdown-project-list/dropdown-project-list.jade @@ -4,7 +4,7 @@ a(href="", title="Projects", tg-nav="projects") div.navbar-dropdown.dropdown-project-list ul a(href="#", - ng-repeat="project in vm.projects.recents", + tg-repeat="project in vm.projects", ng-bind="::project.name" tg-nav="project:project=project.slug") diff --git a/app/modules/navigation-bar/navigation-bar.directive.coffee b/app/modules/navigation-bar/navigation-bar.directive.coffee index 44607883..a372521d 100644 --- a/app/modules/navigation-bar/navigation-bar.directive.coffee +++ b/app/modules/navigation-bar/navigation-bar.directive.coffee @@ -1,7 +1,8 @@ NavigationBarDirective = (projectsService) -> link = (scope, el, attrs, ctrl) -> scope.vm = {} - scope.vm.projects = projectsService.projects + + taiga.defineImmutableProperty(scope.vm, "projects", () -> projectsService.projects.get("recents")) directive = { templateUrl: "navigation-bar/navigation-bar.html" diff --git a/app/modules/navigation-bar/navigation-bar.jade b/app/modules/navigation-bar/navigation-bar.jade index c003c61a..18060a7d 100644 --- a/app/modules/navigation-bar/navigation-bar.jade +++ b/app/modules/navigation-bar/navigation-bar.jade @@ -26,7 +26,7 @@ nav.navbar include ../../svg/dashboard.svg - div.topnav-dropdown-wrapper(ng-show="vm.projects.recents", tg-dropdown-project-list) + div.topnav-dropdown-wrapper(ng-show="vm.projects.size", tg-dropdown-project-list) //div.topnav-dropdown-wrapper(tg-dropdown-organization-list) div.topnav-dropdown-wrapper(tg-dropdown-user) diff --git a/app/modules/projects/listing/listing.directive.coffee b/app/modules/projects/listing/listing.directive.coffee index 4879088e..71a9ff8f 100644 --- a/app/modules/projects/listing/listing.directive.coffee +++ b/app/modules/projects/listing/listing.directive.coffee @@ -16,7 +16,8 @@ ProjectsListingDirective = (projectsService) -> itemEl = ui.item project = itemEl.scope().project index = itemEl.index() - sorted_project_ids = _.map(scope.vm.projects.all, (p) -> p.id) + + sorted_project_ids = _.map(scope.vm.projects.toArray(), (p) -> p.id) sorted_project_ids = _.without(sorted_project_ids, project.id) sorted_project_ids.splice(index, 0, project.id) sortData = [] @@ -25,7 +26,7 @@ ProjectsListingDirective = (projectsService) -> projectsService.bulkUpdateProjectsOrder(sortData) - taiga.defineImmutableProperty(scope.vm, "projects", projectsService.projects) + taiga.defineImmutableProperty(scope.vm, "projects", () -> projectsService.projects.get("all")) scope.vm.newProject = -> projectsService.newProject() diff --git a/app/modules/projects/listing/listing.jade b/app/modules/projects/listing/listing.jade index 513fd18d..643da715 100644 --- a/app/modules/projects/listing/listing.jade +++ b/app/modules/projects/listing/listing.jade @@ -10,16 +10,16 @@ div.project-list-wrapper.centered section.project-list-section div.project-list ul.js-sortable - li.project-list-single(tg-bind-scope, ng-repeat="project in vm.projects.all") + li.project-list-single(tg-bind-scope, tg-repeat="project in vm.projects") div.project-list-single-left div.project-list-single-title h1 a(href="#", ng-bind="::project.name", tg-nav="project:project=project.slug", title="{{ ::project.name }}") - span {{project.is_private}} + span {{::project.is_private}} p {{ ::project.description | limitTo:300 }} span(ng-if="::project.description.length > 300") ... - div.project-list-single-tags.tags-container(ng-if="project.tags") + div.project-list-single-tags.tags-container(ng-if="::project.tags") div.tags-block(tg-colorize-tags="project.tags", tg-colorize-tags-type="backlog") div.project-list-single-right diff --git a/app/modules/projects/projects.service.coffee b/app/modules/projects/projects.service.coffee index 1bd6e980..3d3ae9fa 100644 --- a/app/modules/projects/projects.service.coffee +++ b/app/modules/projects/projects.service.coffee @@ -2,7 +2,7 @@ class ProjectsService extends taiga.Service @.$inject = ["$q", "$tgResources", "$rootScope", "$projectUrl"] constructor: (@q, @rs, @rootScope, @projectUrl) -> - @.projects = {all: [], recent: []} + @.projects = Immutable.Map() @.inProgress = false @.projectsPromise = null @.fetchProjects() @@ -15,8 +15,10 @@ class ProjectsService extends taiga.Service for project in projects project.url = @projectUrl.get(project) - @.projects.recents = projects.slice(0, 10) - @.projects.all = projects + @.projects = Immutable.fromJS({ + all: projects, + recents: projects.slice(0, 10) + }) return @.projects diff --git a/bower.json b/bower.json index b34f6910..7989c254 100644 --- a/bower.json +++ b/bower.json @@ -78,7 +78,8 @@ "angular-translate-loader-static-files": "~2.6.1", "angular-translate-interpolation-messageformat": "~2.6.1", "ngInfiniteScroll": "1.0.0", - "eventemitter2": "~0.4.14" + "eventemitter2": "~0.4.14", + "immutable": "~3.7.2" }, "resolutions": { "lodash": "~2.4.1", diff --git a/gulpfile.js b/gulpfile.js index dc280afd..b1017fb8 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -144,9 +144,11 @@ paths.libs = [ paths.vendor + "messageformat/locale/*.js", paths.vendor + "ngInfiniteScroll/build/ng-infinite-scroll.js", paths.vendor + "eventemitter2/lib/eventemitter2.js", + paths.vendor + "immutable/dist/immutable.js", paths.app + "js/jquery.ui.git-custom.js", paths.app + "js/jquery-ui.drag-multiple-custom.js", paths.app + "js/jquery.ui.touch-punch.min.js", + paths.app + "js/tg-repeat.js", paths.app + "js/sha1-custom.js" ];