tg-repeat

stable
Juanfran 2015-04-27 10:09:27 +02:00
parent 3f34ed1412
commit c9ee2dbf84
12 changed files with 344 additions and 19 deletions

View File

@ -350,7 +350,8 @@ modules = [
"ngRoute",
"ngAnimate",
"pascalprecht.translate",
"infinite-scroll"
"infinite-scroll",
"tgRepeat"
].concat(_.map(@.taigaContribPlugins, (plugin) -> plugin.module))
# Main module definition

View File

@ -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

310
app/js/tg-repeat.js Normal file
View File

@ -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);
})();

View File

@ -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()

View File

@ -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")

View File

@ -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"

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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"
];